Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0061c73b8 | |||
| 23d636fd97 | |||
| fce26cf881 | |||
| 91f6f67257 | |||
| 4caaa1db97 | |||
| 12681e4b1b | |||
| 91e05e3c26 | |||
| 9d0983b666 | |||
| 13c8250488 | |||
| e5305ffa94 | |||
| 46be7a54ea | |||
| bca61322b5 | |||
| fc6f619550 | |||
| 91aa6fffbf | |||
| 5862401958 | |||
| ff5a047078 | |||
| dc415a77fd | |||
| e385734275 | |||
| 3a46b210bb | |||
| 943825d6a0 | |||
| d16df1e224 | |||
| 34c097aee2 | |||
| acd7e8dc52 | |||
| ceaeb90a22 | |||
| 57e26fb22f | |||
| 8431b1400c | |||
| ed01604a18 | |||
| 05fe7ffa90 | |||
| e97c3389fe | |||
| 4526278fa0 | |||
| 80850c81f7 | |||
| 707f29e8e4 | |||
| 389200f855 | |||
| 81406e7a1b | |||
| c78b84e186 |
@@ -1,3 +1,18 @@
|
||||
# Shell scripts must keep LF line endings so they run on Linux/macOS CI and
|
||||
# local shells regardless of the committer's platform autocrlf setting.
|
||||
*.sh text eol=lf
|
||||
|
||||
# The cbindgen-generated C header is committed; pin it to LF so its CI drift
|
||||
# check (regenerate + `git diff`) never trips on a CRLF normalization.
|
||||
bindings/c/include/wickra.h text eol=lf
|
||||
|
||||
# C# sources (including the generated binding) are pinned to LF so the committed
|
||||
# files stay stable regardless of the committer's autocrlf setting.
|
||||
*.cs text eol=lf
|
||||
|
||||
# Go sources (including the generated binding) are pinned to LF so gofmt's CI
|
||||
# check never trips on a CRLF checkout on Windows.
|
||||
*.go text eol=lf
|
||||
go.mod text eol=lf
|
||||
go.sum text eol=lf
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ assignees: ""
|
||||
## Environment
|
||||
|
||||
- Wickra version:
|
||||
- Language / binding: <!-- Rust crate / Python / Node / WASM -->
|
||||
- Language / binding: <!-- Rust crate / Python / Node / WASM / C ABI / C# (.NET) / Go -->
|
||||
- OS and architecture:
|
||||
- Rust / Python / Node version (If relevant):
|
||||
- Rust / Python / Node / .NET version (If relevant):
|
||||
|
||||
## Additional context
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ assignees: []
|
||||
| Binding version | `e.g. python 0.4.2 / node 0.4.2` |
|
||||
| OS / arch | `e.g. Windows 11 x86_64, Linux glibc` |
|
||||
| Rust toolchain | `rustc --version` (If building from source) |
|
||||
| Python / Node version | `python --version` / `node --version` |
|
||||
| Python / Node / .NET version | `python --version` / `node --version` / `dotnet --version` |
|
||||
|
||||
## Minimal reproducer
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ assignees: ""
|
||||
- [ ] Should be exposed in the Python binding
|
||||
- [ ] Should be exposed in the Node binding
|
||||
- [ ] Should be exposed in the WASM binding
|
||||
- [ ] Should be exposed in the C ABI
|
||||
- [ ] Should be exposed in the C# / .NET binding
|
||||
- [ ] Should be exposed in the Go binding
|
||||
|
||||
## Additional context
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ assignees: []
|
||||
## Affected code path
|
||||
|
||||
- Indicator / API: `e.g. EMA.update`
|
||||
- Binding: `Rust / Python / Node / Wasm`
|
||||
- Binding: `Rust / Python / Node / Wasm / C ABI / C# (.NET) / Go`
|
||||
- Hot loop or one-shot call?
|
||||
|
||||
## Versions compared
|
||||
|
||||
@@ -33,4 +33,4 @@ import wickra as ta
|
||||
## Environment (Only if relevant)
|
||||
|
||||
- Wickra version: `e.g. 0.4.2`
|
||||
- Binding: `Rust / Python / Node / Wasm`
|
||||
- Binding: `Rust / Python / Node / Wasm / C ABI / C# (.NET) / Go`
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` is clean.
|
||||
- [ ] `cargo test --workspace` passes.
|
||||
- [ ] New behaviour has tests; bug fixes have a regression test.
|
||||
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings
|
||||
- [ ] Public API changes are mirrored in the Python / Node / WASM bindings, and the C ABI + C# + Go bindings are regenerated
|
||||
and their type stubs (If applicable).
|
||||
- [ ] The relevant page on the [documentation site](https://docs.wickra.org)
|
||||
and the `README.md` are updated (If applicable). Docs edits go to a
|
||||
|
||||
@@ -23,6 +23,9 @@ Please fill in the sections below. Delete any that don't apply.
|
||||
- [ ] Python binding (`bindings/python`)
|
||||
- [ ] Node.js binding (`bindings/node`)
|
||||
- [ ] WebAssembly binding (`bindings/wasm`)
|
||||
- [ ] C ABI (`bindings/c`)
|
||||
- [ ] C# / .NET binding (`bindings/csharp`)
|
||||
- [ ] Go binding (`bindings/go`)
|
||||
- [ ] Examples / docs
|
||||
|
||||
## Linked issues
|
||||
|
||||
@@ -53,6 +53,22 @@ jobs:
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Warm cargo registry (retry transient DNS/registry flakes)
|
||||
shell: bash
|
||||
# CARGO_NET_RETRY rides out short blips, but a longer runner DNS outage
|
||||
# outlasts cargo's rapid in-process retries: the crates.io index fetch
|
||||
# the first cargo step does ("Could not resolve host: index.crates.io")
|
||||
# then fails the whole job. Pre-fetch the dependency graph here with real
|
||||
# backoff so clippy/build/test resolve from the warmed local cache.
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if cargo fetch; then exit 0; fi
|
||||
echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
echo "::error::cargo fetch still failing after 5 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Format check
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
@@ -232,6 +248,19 @@ jobs:
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Warm cargo registry (retry transient DNS/registry flakes)
|
||||
shell: bash
|
||||
# See the rust job: pre-fetch with backoff so the clippy index update
|
||||
# can't fail the job on a transient "Could not resolve host" DNS blip.
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if cargo fetch; then exit 0; fi
|
||||
echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
echo "::error::cargo fetch still failing after 5 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Clippy (bindings, all targets)
|
||||
run: cargo clippy -p wickra-node -p wickra-python --all-targets -- -D warnings
|
||||
|
||||
@@ -272,6 +301,19 @@ jobs:
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Warm cargo registry (retry transient DNS/registry flakes)
|
||||
shell: bash
|
||||
# See the rust job: pre-fetch with backoff so the first cargo step can't
|
||||
# fail the job on a transient "Could not resolve host" DNS blip.
|
||||
run: |
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if cargo fetch; then exit 0; fi
|
||||
echo "::warning::cargo fetch failed (attempt $attempt/5) — likely a registry/DNS flake; retrying in $((attempt * 20))s..."
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
echo "::error::cargo fetch still failing after 5 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Build on MSRV
|
||||
run: cargo build ${{ matrix.packages }} --verbose
|
||||
|
||||
@@ -580,6 +622,210 @@ jobs:
|
||||
working-directory: bindings/node
|
||||
run: node --test __tests__/
|
||||
|
||||
c-abi:
|
||||
name: C ABI on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Install cbindgen
|
||||
uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15
|
||||
timeout-minutes: 10 # fail fast on a stuck download instead of hanging the job
|
||||
with:
|
||||
tool: cbindgen
|
||||
|
||||
- name: Build the C ABI library (cdylib + staticlib)
|
||||
run: cargo build -p wickra-c --release
|
||||
|
||||
- name: Rust unit tests
|
||||
run: cargo test -p wickra-c
|
||||
|
||||
# The generated header is platform-independent, so checking drift on one OS
|
||||
# is enough — and avoids a spurious CRLF/LF diff on the Windows runner.
|
||||
- name: Check the committed header is in sync with cbindgen
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
cbindgen --config bindings/c/cbindgen.toml --crate wickra-c --output bindings/c/include/wickra.h
|
||||
if ! git diff --quiet -- bindings/c/include/wickra.h; then
|
||||
echo "::error::bindings/c/include/wickra.h is out of sync — run cbindgen and commit the result"
|
||||
git --no-pager diff -- bindings/c/include/wickra.h
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The real cross-language test: a foreign C consumer links the generated
|
||||
# header + the compiled library and runs. If this passes on all three OSes,
|
||||
# every C-capable language can link the same way.
|
||||
- name: Build and run the C smoke example (CMake + ctest)
|
||||
shell: bash
|
||||
run: |
|
||||
cmake -S examples/c -B examples/c/build
|
||||
cmake --build examples/c/build --config Release
|
||||
ctest --test-dir examples/c/build -C Release --output-on-failure
|
||||
|
||||
csharp:
|
||||
name: C# on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# The binding links against the C ABI hub at runtime; build it first so the
|
||||
# DllImportResolver finds target/release/wickra.{dll,so,dylib}. .NET 8 SDK is
|
||||
# preinstalled on the GitHub runners, so no setup-dotnet step is needed.
|
||||
- name: Build the C ABI library
|
||||
run: cargo build -p wickra-c --release
|
||||
|
||||
- name: .NET info
|
||||
run: dotnet --info
|
||||
|
||||
- name: Test the C# binding
|
||||
run: dotnet test bindings/csharp/Wickra.Tests/Wickra.Tests.csproj -c Release
|
||||
|
||||
- name: Build the C# examples
|
||||
shell: bash
|
||||
run: |
|
||||
for d in streaming backtest multi_timeframe parallel_assets \
|
||||
strategy_rsi_mean_reversion strategy_macd_adx strategy_bollinger_squeeze \
|
||||
fetch_btcusdt live_binance; do
|
||||
dotnet build "examples/csharp/$d" -c Release
|
||||
done
|
||||
|
||||
# Run only the offline examples (fetch_btcusdt / live_binance need network).
|
||||
- name: Run the offline C# examples
|
||||
shell: bash
|
||||
run: |
|
||||
for d in streaming backtest multi_timeframe parallel_assets \
|
||||
strategy_rsi_mean_reversion strategy_macd_adx strategy_bollinger_squeeze; do
|
||||
dotnet run --project "examples/csharp/$d" -c Release --no-build
|
||||
done
|
||||
|
||||
go:
|
||||
name: Go on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Go is not reliably on PATH on every runner image, so install it
|
||||
# explicitly. cache: false — the cargo build dominates and the modules
|
||||
# have no external Go deps worth caching.
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
continue-on-error: true
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: "stable"
|
||||
cache: false
|
||||
|
||||
- name: Retry Go setup (CDN flake)
|
||||
if: steps.setup-go.outcome == 'failure'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::warning::setup-go failed (likely CDN flake), waiting 30s before retry..."
|
||||
sleep 30
|
||||
|
||||
- name: Set up Go (retry)
|
||||
if: steps.setup-go.outcome == 'failure'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: "stable"
|
||||
cache: false
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
# The binding links against the C ABI hub via cgo; build it first and stage
|
||||
# the platform library under bindings/go/lib so cgo's LDFLAGS find it. Go is
|
||||
# preinstalled on the GitHub runners, so no setup-go step is needed.
|
||||
- name: Build the C ABI library
|
||||
run: cargo build -p wickra-c --release
|
||||
|
||||
- name: Stage the native library
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p bindings/go/lib
|
||||
case "$RUNNER_OS" in
|
||||
Linux) cp target/release/libwickra.so bindings/go/lib/ ;;
|
||||
macOS) cp target/release/libwickra.dylib bindings/go/lib/ ;;
|
||||
Windows) cp target/release/wickra.dll bindings/go/lib/ ;;
|
||||
esac
|
||||
|
||||
- name: Go info
|
||||
run: go version
|
||||
|
||||
- name: Check gofmt
|
||||
shell: bash
|
||||
run: |
|
||||
unformatted="$(gofmt -l bindings/go examples/go)"
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "gofmt needed on:"; echo "$unformatted"; exit 1
|
||||
fi
|
||||
|
||||
- name: Vet and test the Go binding
|
||||
shell: bash
|
||||
# On Windows there is no rpath; the loader resolves wickra.dll via PATH.
|
||||
run: |
|
||||
export PATH="$PWD/bindings/go/lib:$PATH"
|
||||
cd bindings/go
|
||||
go vet ./...
|
||||
go test ./...
|
||||
|
||||
- name: Build the Go examples
|
||||
shell: bash
|
||||
run: |
|
||||
export PATH="$PWD/bindings/go/lib:$PATH"
|
||||
cd examples/go
|
||||
go build ./...
|
||||
|
||||
# Run only the offline examples (fetch_btcusdt / live_binance need network).
|
||||
- name: Run the offline Go examples
|
||||
shell: bash
|
||||
run: |
|
||||
export PATH="$PWD/bindings/go/lib:$PATH"
|
||||
cd examples/go
|
||||
for d in streaming backtest multi_timeframe parallel_assets \
|
||||
strategy_rsi_mean_reversion strategy_macd_adx strategy_bollinger_squeeze; do
|
||||
go run "./$d"
|
||||
done
|
||||
|
||||
# The cross-library benchmark has moved to a dedicated scheduled workflow
|
||||
# (.github/workflows/bench.yml) — see audit finding R10. It runs nightly
|
||||
# at 03:00 UTC and on-demand via `workflow_dispatch`, and is no longer on
|
||||
|
||||
@@ -569,9 +569,146 @@ jobs:
|
||||
# the old "publish, then upload provenance" order would have the provenance
|
||||
# upload rejected once immutability is enabled.
|
||||
# --------------------------------------------------------------------------
|
||||
# --------------------------------------------------------------------------
|
||||
# C ABI native libraries (bindings/c) — built per target on a native runner
|
||||
# (no cross toolchain needed) and attached to the GitHub Release as the
|
||||
# distribution channel. There is no package registry for the C ABI.
|
||||
# --------------------------------------------------------------------------
|
||||
c-abi-build:
|
||||
name: C ABI library (${{ matrix.target }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { host: ubuntu-latest, target: x86_64-unknown-linux-gnu }
|
||||
- { host: ubuntu-24.04-arm, target: aarch64-unknown-linux-gnu }
|
||||
- { host: macos-latest, target: x86_64-apple-darwin }
|
||||
- { host: macos-latest, target: aarch64-apple-darwin }
|
||||
- { host: windows-latest, target: x86_64-pc-windows-msvc }
|
||||
- { host: windows-11-arm, target: aarch64-pc-windows-msvc }
|
||||
runs-on: ${{ matrix.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable branch, 2026-03-27
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
continue-on-error: true # cache is an optimisation; never block on a stuck/slow restore
|
||||
timeout-minutes: 6
|
||||
|
||||
- name: Build the C ABI library (cdylib + staticlib)
|
||||
run: cargo build -p wickra-c --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Package header + libraries
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
dir="wickra-c-${{ matrix.target }}"
|
||||
mkdir -p "$dir/include" "$dir/lib"
|
||||
cp bindings/c/include/wickra.h bindings/c/include/wickra.hpp "$dir/include/"
|
||||
for f in libwickra.so libwickra.a libwickra.dylib wickra.dll wickra.dll.lib wickra.lib; do
|
||||
src="target/${{ matrix.target }}/release/$f"
|
||||
[ -f "$src" ] && cp "$src" "$dir/lib/"
|
||||
done
|
||||
tar -czf "$dir.tar.gz" "$dir"
|
||||
echo "packaged $dir:"; ls -lR "$dir"
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: c-abi-${{ matrix.target }}
|
||||
path: wickra-c-${{ matrix.target }}.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
# Pack and publish the .NET binding to NuGet. Independent of the GitHub-release
|
||||
# job so a C# hiccup never blocks the C/C++ asset release. Authentication uses
|
||||
# NuGet Trusted Publishing (OIDC) — no long-lived API key. The 'wickra-release'
|
||||
# trusted-publishing policy on nuget.org (owner KingchenC, repo wickra-lib/wickra,
|
||||
# workflow release.yml) exchanges the GitHub OIDC token for a short-lived key.
|
||||
csharp-publish:
|
||||
name: Publish to NuGet
|
||||
needs: c-abi-build
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # request the GitHub OIDC token for trusted publishing
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download the C ABI native libraries
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: c-abi-*
|
||||
path: c-abi-artifacts
|
||||
|
||||
- name: Stage native libraries into runtimes/<rid>/native
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
declare -A RID=(
|
||||
[x86_64-unknown-linux-gnu]=linux-x64
|
||||
[aarch64-unknown-linux-gnu]=linux-arm64
|
||||
[x86_64-apple-darwin]=osx-x64
|
||||
[aarch64-apple-darwin]=osx-arm64
|
||||
[x86_64-pc-windows-msvc]=win-x64
|
||||
[aarch64-pc-windows-msvc]=win-arm64
|
||||
)
|
||||
base=bindings/csharp/Wickra/runtimes
|
||||
for target in "${!RID[@]}"; do
|
||||
archive=$(find c-abi-artifacts -name "wickra-c-$target.tar.gz" | head -1)
|
||||
if [ -z "$archive" ]; then
|
||||
echo "::error::missing native artifact for $target"; exit 1
|
||||
fi
|
||||
tmp=$(mktemp -d); tar -xzf "$archive" -C "$tmp"
|
||||
dest="$base/${RID[$target]}/native"; mkdir -p "$dest"
|
||||
for f in libwickra.so libwickra.dylib wickra.dll; do
|
||||
src=$(find "$tmp" -name "$f" | head -1)
|
||||
[ -n "$src" ] && cp "$src" "$dest/"
|
||||
done
|
||||
echo "staged ${RID[$target]}:"; ls -l "$dest"
|
||||
done
|
||||
|
||||
- name: Pack
|
||||
shell: bash
|
||||
run: |
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
dotnet pack bindings/csharp/Wickra/Wickra.csproj -c Release -p:Version="$version" -o nupkg
|
||||
|
||||
# Exchange the GitHub OIDC token for a short-lived (~1h) NuGet API key.
|
||||
# 'user' is the nuget.org profile name (the package owner), not an email.
|
||||
- name: NuGet login (OIDC -> temporary API key)
|
||||
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1.2.0
|
||||
id: nuget_login
|
||||
with:
|
||||
user: KingchenC
|
||||
|
||||
# Pass the temporary key through the environment (not string-interpolated
|
||||
# into the script) so it cannot be parsed as shell — avoids template injection.
|
||||
- name: Push to NuGet
|
||||
shell: bash
|
||||
env:
|
||||
NUGET_API_KEY: ${{ steps.nuget_login.outputs.NUGET_API_KEY }}
|
||||
run: |
|
||||
dotnet nuget push "nupkg/*.nupkg" --api-key "$NUGET_API_KEY" \
|
||||
--source https://api.nuget.org/v3/index.json --skip-duplicate
|
||||
|
||||
- name: Upload the NuGet package as a build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: nuget-package
|
||||
path: nupkg/*.nupkg
|
||||
if-no-files-found: error
|
||||
|
||||
github-release:
|
||||
name: Attach assets to the draft GitHub Release
|
||||
needs: [cargo-publish, python-publish, node-publish, wasm-publish]
|
||||
needs: [cargo-publish, python-publish, node-publish, wasm-publish, c-abi-build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -644,7 +781,7 @@ jobs:
|
||||
# the provenance bundle is attached (P24, immutability-ready).
|
||||
draft: true
|
||||
body: |
|
||||
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries.
|
||||
Wickra ${{ github.ref_name }} — streaming-first technical indicators across 4 language registries plus a C ABI.
|
||||
|
||||
### Install
|
||||
|
||||
@@ -665,6 +802,9 @@ jobs:
|
||||
darwin-arm64, win32-x64-msvc)
|
||||
- `wickra-*.tgz` — npm-pack tarballs (main package + per-platform subpackages + WASM)
|
||||
- `*.crate` — cargo source crates (wickra-core, wickra-data, wickra)
|
||||
- `wickra-c-<target>.tar.gz` — C ABI: `include/wickra.h` + `wickra.hpp`
|
||||
and the cdylib/staticlib per target (linux/macos/windows × x64/arm64),
|
||||
the hub for C / C++ / Go / C# / Java / R
|
||||
|
||||
### Auto-generated changelog
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ name: Sync indicator count
|
||||
# 2. GitHub repo "About" description — synced on push to main / v* tag
|
||||
# 3. Docs site: index.md / overview.md / Indicators-Overview.md
|
||||
# (wickra-lib/wickra-docs) — synced on push to main / v* tag*
|
||||
# 4. Marketing site count (wickra-lib/webpage: index.md /
|
||||
# 4. Marketing site count (wickra-lib/webpage: index.md / about.md /
|
||||
# .vitepress/config.ts) — push to main / v* tag*
|
||||
# 5. org profile README count (wickra-lib/.github, profile/README.md)
|
||||
# — synced on push to main / v* tag*
|
||||
@@ -42,10 +42,10 @@ name: Sync indicator count
|
||||
# single source of truth for what the bindings reach.
|
||||
#
|
||||
# Design: on PRs this workflow is a READ-ONLY check. The indicator wiring
|
||||
# (ScriptHelpers/_common.py wire_readme_counter) bumps both README.md and
|
||||
# docs/README.md inside the author's code commit, so the counter is already
|
||||
# correct by the time CI runs. If it is not, the check below fails loud and
|
||||
# asks the author to re-run the wiring — it never pushes a fix-up commit.
|
||||
# bumps both README.md and docs/README.md inside the author's code commit, so
|
||||
# the counter is already correct by the time CI runs. If it is not, the check
|
||||
# below fails loud and asks the author to re-run the wiring — it never pushes a
|
||||
# fix-up commit.
|
||||
#
|
||||
# (An earlier version pushed a GITHUB_TOKEN "sync indicator count" commit to
|
||||
# the PR head. Because GITHUB_TOKEN pushes trigger no workflows, that commit
|
||||
@@ -149,7 +149,7 @@ jobs:
|
||||
# actually live (Cloudflare Pages, P8.1); merging this PR is therefore
|
||||
# gated on the domain resolving, otherwise the About link would 404.
|
||||
homepage="https://docs.wickra.org"
|
||||
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, and WebAssembly bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
|
||||
desc="Streaming-first technical indicators with a Rust core and Python, Node.js, WebAssembly, C ABI, .NET, and Go bindings. ${n} indicators, O(1) per-tick updates, no system dependencies. Drop-in TA-Lib replacement."
|
||||
# Enforce the homepage unconditionally — it is a constant, so this both
|
||||
# corrects the stale kingchenc URL and self-heals any future drift.
|
||||
# Same Administration-write permission as --description (no extra scope).
|
||||
@@ -366,14 +366,14 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
cd webpage-count
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md .vitepress/config.ts
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" index.md about.md .vitepress/config.ts
|
||||
if git diff --quiet; then
|
||||
echo "Webpage indicator count unchanged."
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add index.md .vitepress/config.ts
|
||||
git add index.md about.md .vitepress/config.ts
|
||||
git commit -m "chore: sync indicator count to ${n}"
|
||||
if ! git push 2>/dev/null; then
|
||||
echo "::warning::push to wickra-lib/webpage failed — ABOUT_SYNC_TOKEN likely lacks write (findings P10.0a)."
|
||||
|
||||
+21
-8
@@ -27,16 +27,28 @@ or replace lives behind a separate crate boundary.
|
||||
│ no I/O, no deps │ │ optional features │
|
||||
└──────────────────────┘ └────────────────────┘
|
||||
▲
|
||||
│ (every binding wraps the same core)
|
||||
│
|
||||
┌────────────┴───────────┬─────────────────────┐
|
||||
│ │ │
|
||||
┌──▼──────┐ ┌───────▼──────┐ ┌───────▼────────┐
|
||||
│ Python │ │ Node │ │ WASM │
|
||||
│ (PyO3) │ │ (napi-rs) │ │ (wasm-bindgen) │
|
||||
└─────────┘ └──────────────┘ └────────────────┘
|
||||
│ every binding wraps the same core
|
||||
┌────────────┼────────────┬────────────────┐
|
||||
│ │ │ │
|
||||
┌──▼───────┐ ┌──▼───────┐ ┌──▼───────────┐ ┌──▼──────────────────┐
|
||||
│ Python │ │ Node │ │ WASM │ │ C ABI (cbindgen) │
|
||||
│ (PyO3) │ │ (napi-rs)│ │(wasm-bindgen)│ │ cdylib + header │
|
||||
└──────────┘ └──────────┘ └──────────────┘ └─────────┬───────────┘
|
||||
│ linked by
|
||||
┌──────────▼──────────┐
|
||||
│ C · C++ · C# · Go │
|
||||
│ · Java · R │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
Python, Node and WASM are *native* Rust bindings (PyO3 / napi-rs /
|
||||
wasm-bindgen). The C ABI is the *hub* every other C-capable language links
|
||||
against: it builds to a `cdylib`/`staticlib` plus a generated `wickra.h`, and
|
||||
downstream languages link that one artifact rather than each re-wrapping the
|
||||
core. C and C++ link it directly; the **C# / .NET** binding (`bindings/csharp`,
|
||||
on NuGet) and the **Go** binding (`bindings/go`, cgo) are generated from
|
||||
`wickra.h`, with Java / R planned the same way.
|
||||
|
||||
| Crate | Path | What it owns | Public deps |
|
||||
|---|---|---|---|
|
||||
| `wickra-core` | `crates/wickra-core` | every indicator, the `Indicator` trait, `BatchExt`, `Candle`/`Tick` types, `Error` | `thiserror`, `rayon` (parallel batch) |
|
||||
@@ -45,6 +57,7 @@ or replace lives behind a separate crate boundary.
|
||||
| `wickra-python` | `bindings/python` | `_wickra` PyO3 module + Python package | `pyo3`, `numpy`, depends on `wickra-core` |
|
||||
| `wickra-node` | `bindings/node` | NAPI-RS native binding | `napi`, depends on `wickra-core` |
|
||||
| `wickra-wasm` | `bindings/wasm` | WebAssembly binding | `wasm-bindgen`, depends on `wickra-core` |
|
||||
| `wickra-c` | `bindings/c` | C ABI hub — `cdylib`/`staticlib` + generated `wickra.h` (cbindgen) | depends on `wickra-core` |
|
||||
| `wickra-examples` | `examples/rust` | runnable binary examples | depends on `wickra`, `wickra-data` |
|
||||
|
||||
The `fuzz/` directory is **excluded** from the workspace (it has its own
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Benchmarks
|
||||
|
||||
Read these as **relative** speedups on identical input — absolute µs depend on
|
||||
CPU, memory clock and OS scheduler, not a universal contract. **Streaming is the
|
||||
headline**: it is where Wickra's design pays off and where the gap is measured in
|
||||
orders of magnitude, not percent. The batch numbers come second and are shown
|
||||
honestly — the leanest crates edge Wickra out on the simple recurrences, and that
|
||||
is a deliberate trade for warmup/NaN semantics, not a ceiling.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release: `lto = "fat"`, `codegen-units = 1`), Python 3.12.
|
||||
- **Reproduce yourself:**
|
||||
- Rust core vs Rust crates: `cargo bench -p wickra-bench`
|
||||
- Python vs Python libs: `pip install -e bindings/python[bench]` then
|
||||
`python -m benchmarks.compare_libraries` (auto-detects installed peers).
|
||||
|
||||
## 1. Streaming — the structural win
|
||||
|
||||
Live trading feeds one tick at a time. Wickra updates every indicator in **O(1)**;
|
||||
batch-only libraries (TA-Lib, tulipy, finta, pandas-ta) have no incremental API
|
||||
and must recompute the whole history on every tick. Only `talipp` (Python) and
|
||||
`ta-rs` / `yata` (Rust) carry real per-tick state. This is the gap the library
|
||||
was built to expose.
|
||||
|
||||
**Python — per-tick latency** (seed 5 000 bars, then feed ticks one at a time):
|
||||
|
||||
| Indicator | **★ Wickra** | talipp | TA-Lib (recompute) |
|
||||
|------------------|------------------:|------------------|-----------------------|
|
||||
| SMA(20) | **0.089 µs ★** | 0.96 µs (11×) | 422 µs (4 700×) |
|
||||
| EMA(20) | **0.111 µs ★** | 1.19 µs (11×) | 430 µs (3 900×) |
|
||||
| RSI(14) | **0.061 µs ★** | 0.95 µs (16×) | 298 µs (4 900×) |
|
||||
| MACD(12, 26, 9) | **0.079 µs ★** | 3.30 µs (42×) | 327 µs (4 100×) |
|
||||
| Bollinger(20, 2) | **0.089 µs ★** | 4.97 µs (56×) | 296 µs (3 300×) |
|
||||
|
||||
Against the only other incremental Python peer Wickra is **11–56× faster**;
|
||||
against the recompute-on-every-tick libraries it is **2 800–19 000× faster**
|
||||
(`finta` RSI hits 19 000×). tulipy / pandas-ta land in the same recompute band
|
||||
as TA-Lib.
|
||||
|
||||
**Rust — per-tick latency** (whole 50 000-bar series, lower = faster):
|
||||
|
||||
| Indicator | **★ Wickra** | kand | ta-rs | yata |
|
||||
|------------------|------------------:|-----:|------:|-----:|
|
||||
| SMA(20) | 50 | 38 | 47 | 38 |
|
||||
| EMA(20) | 154 | 69 | 56 | 69 |
|
||||
| RSI(14) | 164 | 216 | 74 | — |
|
||||
| MACD(12, 26, 9) | 275 | 143 | 66 | — |
|
||||
| Bollinger(20, 2) | **128 ★** | 248 | 168 | — |
|
||||
| ATR(14) | 152 | 166 | 61 | — |
|
||||
|
||||
`ta-rs` hands back a bare `f64` from the first tick with no warmup and no
|
||||
validation; it leads several rows by giving those guarantees up. Against `kand`,
|
||||
Wickra wins streaming RSI, Bollinger and ATR. `yata` exposes only SMA/EMA as
|
||||
raw-value methods, so its other rows are omitted rather than faked.
|
||||
|
||||
## 2. Batch — competitive, not the headline
|
||||
|
||||
Whole series in one call. Here hand-tuned C (`tulipy`, TA-Lib) and the leanest
|
||||
Rust crate (`kand`) win the simple recurrences — Wickra trades a few µs per pass
|
||||
for the `None`-warmup, NaN-safety and bit-exact `batch == streaming` guarantees
|
||||
none of them keep. It still wins several rows outright and beats the rest of the
|
||||
field everywhere.
|
||||
|
||||
**Python** (20 000-bar pass, µs/op, lower = faster):
|
||||
|
||||
| Indicator | Wickra | TA-Lib | tulipy | pandas-ta | finta |
|
||||
|------------------|---------:|---------:|---------:|----------:|---------:|
|
||||
| SMA(20) | 22.2 | **15.6** | 15.9 | 32.7 | 290.1 |
|
||||
| EMA(20) | 30.5 | **30.4** | 30.9 | 46.7 | 198.5 |
|
||||
| RSI(14) | 52.3 | 72.0 | **34.2** | 88.8 | 812.3 |
|
||||
| MACD(12, 26, 9) | 129.8 | 111.1 | **38.4** | 286.8 | 716.7 |
|
||||
| Bollinger(20, 2) | 87.2 | 74.6 | **37.9** | 474.3 | 1255.5 |
|
||||
| ATR(14) | 74.7 | 87.3 | **35.5** | — | 3496.4 |
|
||||
|
||||
Wickra beats pandas-ta and finta on every row and TA-Lib on RSI and ATR;
|
||||
tulipy's SIMD C (and TA-Lib on SMA/EMA) lead the remaining rows.
|
||||
|
||||
**Rust** (50 000-bar pass, µs, lower = faster). Only Wickra and `kand` expose a
|
||||
batch API; `ta-rs` and `yata` are streaming-only:
|
||||
|
||||
| Indicator | **★ Wickra** | kand |
|
||||
|------------------|------------------:|-------:|
|
||||
| SMA(20) | 53 | **41** |
|
||||
| EMA(20) | 111 | **71** |
|
||||
| RSI(14) | **221 ★** | 259 |
|
||||
| MACD(12, 26, 9) | 533 | **327** |
|
||||
| Bollinger(20, 2) | **404 ★** | 460 |
|
||||
| ATR(14) | **122 ★** | 169 |
|
||||
|
||||
Run the suite yourself:
|
||||
|
||||
```bash
|
||||
cargo bench -p wickra-bench # Rust core vs kand / ta-rs / yata
|
||||
pip install -e bindings/python[bench] # Python peers
|
||||
python -m benchmarks.compare_libraries
|
||||
```
|
||||
+143
-1
@@ -7,6 +7,133 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.7] - 2026-06-09
|
||||
### Added
|
||||
- **Go binding (`bindings/go`)** — a cgo binding over the C ABI hub exposing all
|
||||
514 indicators as idiomatic types with `New<Indicator>` constructors and
|
||||
`Update`/`Batch`/`Reset`/`Close` methods, generated from `wickra.h`. Handles are
|
||||
freed by `Close()` with a `runtime.SetFinalizer` backstop. Ships a full example
|
||||
suite mirroring the C and C# examples; distributed as a subdirectory module
|
||||
(`go get github.com/wickra-lib/wickra/bindings/go`).
|
||||
|
||||
## [0.7.6] - 2026-06-09
|
||||
### Added
|
||||
- **C# / .NET binding (`bindings/csharp`)** — the first language stecker on the
|
||||
C ABI hub. Exposes all 514 indicators as idiomatic `IDisposable` classes via
|
||||
`[LibraryImport]` source-generated P/Invoke, generated from `wickra.h`. Ships
|
||||
on NuGet as `Wickra` with prebuilt native libraries for six target triples
|
||||
(win/linux/osx × x64/arm64), plus a full example suite mirroring the C examples.
|
||||
|
||||
## [0.7.5] - 2026-06-09
|
||||
### Added
|
||||
- **C ABI (`bindings/c`)** — a `cdylib` + `staticlib` plus a generated
|
||||
`include/wickra.h` exposing all 514 indicators and 10 bar builders over an
|
||||
opaque-handle C ABI: the hub any C-capable language (C, C++, Go, C#, Java, R)
|
||||
links against, complementing the native Python/Node/WASM bindings. Ships a
|
||||
full example suite (streaming, backtest, multi-timeframe, OpenMP parallel
|
||||
fan-out, three educational strategies, and Binance fetch/live over `curl`)
|
||||
mirroring the other bindings, plus an optional `wickra.hpp` C++ RAII wrapper.
|
||||
|
||||
## [0.7.4] - 2026-06-08
|
||||
- **Three-Line Break** — Three-line-break bars (reversal needs N-line break) (`THREE_LINE_BREAK_BARS`).
|
||||
- **Run** — Run bars (consecutive same-direction tick runs) (`RUN_BARS`).
|
||||
- **Imbalance** — Imbalance bars (tick-rule signed imbalance threshold) (`IMBALANCE_BARS`).
|
||||
- **Dollar** — Dollar bars (fixed traded value per bar, Lopez de Prado) (`DOLLAR_BARS`).
|
||||
- **Volume** — Volume bars (fixed traded volume per bar) (`VOLUME_BARS`).
|
||||
- **Tick** — Tick bars (fixed candle count per bar) (`TICK_BARS`).
|
||||
- **Range** — Range bars (fixed price-range bricks) (`RANGE_BARS`).
|
||||
|
||||
## [0.7.3] - 2026-06-08
|
||||
- **M2Measure** — M2 measure (Modigliani; Sharpe expressed in benchmark return units) (`M2Measure`).
|
||||
- **UpsidePotentialRatio** — Upside Potential Ratio (upside mean over downside deviation) (`UpsidePotentialRatio`).
|
||||
- **GainToPainRatio** — Gain-to-Pain Ratio (sum of returns over sum of losses) (`GainToPainRatio`).
|
||||
- **CommonSenseRatio** — Common Sense Ratio (tail ratio times gain-to-pain) (`CommonSenseRatio`).
|
||||
- **KRatio** — K-Ratio (Kestner; equity-curve slope over its standard error) (`KRatio`).
|
||||
- **TailRatio** — Tail Ratio (95th over absolute 5th return percentile) (`TailRatio`).
|
||||
- **MartinRatio** — Martin Ratio (Ulcer Performance Index; return over RMS drawdown) (`MartinRatio`).
|
||||
- **BurkeRatio** — Burke Ratio (return over root-sum-squared drawdowns) (`BurkeRatio`).
|
||||
- **SterlingRatio** — Sterling Ratio (mean return over average drawdown) (`SterlingRatio`).
|
||||
|
||||
## [0.7.2] - 2026-06-08
|
||||
- **Composite Profile** — multi-session composite volume profile exposing POC, VAH and VAL (`CompositeProfile`).
|
||||
- **High/Low Volume Nodes** — highest- and lowest-volume price nodes in the profile (`HighLowVolumeNodes`).
|
||||
- **Profile Shape** — profile shape classification (b/P/D normal) as a numeric code (`ProfileShape`).
|
||||
- **Single Prints** — count of single-print (low-activity) price levels in the profile (`SinglePrints`).
|
||||
- **Naked POC** — most recent untouched (naked) point of control level (`NakedPoc`).
|
||||
|
||||
## [0.7.1] - 2026-06-08
|
||||
- **Open-Interest Momentum** — rate-of-change of open interest over a rolling window (`OpenInterestMomentum`).
|
||||
- **Funding-Implied APR** — annualised funding rate (per-interval funding times intervals per year) (`FundingImpliedApr`).
|
||||
- **Perpetual Premium Index** — relative premium of the mark price over the index price (`PerpetualPremiumIndex`).
|
||||
- **OI-to-Volume Ratio** — open interest divided by taker volume (position turnover proxy) (`OiToVolumeRatio`).
|
||||
- **Estimated Leverage Ratio** — open interest divided by aggregate long+short position size (leverage proxy) (`EstimatedLeverageRatio`).
|
||||
|
||||
## [0.7.0] - 2026-06-08
|
||||
- **Hasbrouck Information Share** — variance-ratio proxy for each venue's share of price discovery (Hasbrouck information share) (`HasbrouckInformationShare`).
|
||||
- **PIN** — probability of informed trading from rolling buy/sell imbalance (EKOP single-window estimator) (`Pin`).
|
||||
- **Trade-Sign Autocorrelation** — lag-1 autocorrelation of the signed trade aggressor (order-flow persistence) (`TradeSignAutocorrelation`).
|
||||
|
||||
## [0.6.9] - 2026-06-08
|
||||
- **Tristar** — a three-doji star reversal: three consecutive dojis with the middle gapped above (bearish) or below (bullish) its neighbours (`Tristar`).
|
||||
- **Harami Cross** — a Harami whose second candle is a contained doji, a stronger reversal than a plain Harami (`HaramiCross`).
|
||||
- **Tower Top/Bottom** — a tall bar, a small pause bar, then a tall opposite bar marking a reversal (`TowerTopBottom`).
|
||||
- **Frying Pan Bottom** — a rounded (U-shaped) accumulation base over the lookback window, confirmed when price recovers above the rim (`FryPanBottom`).
|
||||
- **Dumpling Top** — a rounded (dome-shaped) distribution top over the lookback window, confirmed when price breaks below the start (`DumplingTop`).
|
||||
- **New Price Lines** — flags a run of N consecutive new closing highs (+1) or lows (-1), the eight/ten-new-price-lines exhaustion gauge (`NewPriceLines`).
|
||||
|
||||
## [0.6.8] - 2026-06-08
|
||||
- **Smoothed Heikin-Ashi** — a Heikin-Ashi candle computed from EMA-smoothed OHLC, damping noise into a cleaner trend candle (`SmoothedHeikinAshi`).
|
||||
- **Heikin-Ashi Oscillator** — the Heikin-Ashi candle body (`ha_close − ha_open`), optionally EMA-smoothed, as a zero-line oscillator (`HeikinAshiOscillator`).
|
||||
- **Three Line Break** — the trend direction of a line-break chart, reversing only when the close breaks the extreme of the last N lines (`ThreeLineBreak`).
|
||||
- **Equivolume** — a chart box whose height is the bar range and whose width is volume-relative, fusing price range with activity (`Equivolume`).
|
||||
- **CandleVolume** — a candle whose body is close-minus-open and whose width is volume-relative, a volume-weighted candle chart (`CandleVolume`).
|
||||
|
||||
## [0.6.7] - 2026-06-08
|
||||
- **TD Camouflage** — a DeMark qualifier flagging hidden intrabar strength or weakness against the prior close (`TDCamouflage`).
|
||||
- **TD Clop** — a DeMark two-bar open/close engulfing reversal where the bar opens beyond and closes back across the prior body (`TDClop`).
|
||||
- **TD Clopwin** — the inside-body cousin of TD Clop, marking a compression bar whose direction hints at the next move (`TDClopwin`).
|
||||
- **TD Propulsion** — a DeMark continuation thrust that opens on the trend side and closes beyond the prior bar's extreme (`TDPropulsion`).
|
||||
- **TD Trap** — an inside ("trap") bar followed by a close beyond its range, triggering a directional breakout signal (`TDTrap`).
|
||||
- **TD D-Wave** — a streaming Elliott-style swing-wave counter labelling the market's 1–5 impulse / A–C correction sequence (`TDDWave`).
|
||||
- **TD Moving Averages** — the DeMark ST1 (fast) and ST2 (slow) median-price trend ribbon whose crossover frames the trend (`TDMovingAverage`).
|
||||
|
||||
## [0.6.6] - 2026-06-08
|
||||
- **Pivot Reversal** — a breakout signal when price closes through the most recently confirmed swing pivot (`PIVOT_REVERSAL`).
|
||||
- **Volume-Weighted Support/Resistance** — a band whose edges are the volume-weighted average of recent highs and lows (`VOLUME_WEIGHTED_SR`).
|
||||
- **Andrews Pitchfork** — median line and two parallels projected from the last three swing pivots (`ANDREWS_PITCHFORK`).
|
||||
- **Murrey Math Lines** — T. H. Murrey's eighths grid over the recent trading range, each level acting as support/resistance (`MURREY_MATH_LINES`).
|
||||
- **Central Pivot Range** — the classic pivot flanked by two central levels gauging the day's expected character (`CENTRAL_PIVOT_RANGE`).
|
||||
- **Faster scalar batch paths** — `Ema`, `Rsi`, `BollingerBands`, `MacdIndicator` and `Atr` gained dedicated batch fast paths (used by the Python bindings) that strip per-element `Option`/validation overhead and the intermediate `Vec<Option<_>>` allocation, while staying *bit-for-bit* equal to replaying `update` (including the SMA/Bollinger drift-reseed). Python batch is ~2× faster on EMA/RSI/MACD/ATR; streaming is unchanged.
|
||||
- **Cross-library benchmark refresh** — `benchmarks/compare_libraries.py` now measures the median across timing rounds (`--rounds` / `--streaming-rounds`), adds `--skip-batch` / `--skip-streaming`, and drives every peer through the streaming arena (recompute for batch-only libraries). `wickra-bench` compares the batch fast paths against `kand`.
|
||||
|
||||
## [0.6.5] - 2026-06-07
|
||||
- **Autocorrelation Periodogram** — Ehlers autocorrelation periodogram: dominant cycle period estimate (`AUTOCORRPGRAM`).
|
||||
- **Even Better Sinewave** — Ehlers Even Better Sinewave: normalized cycle-phase oscillator (`EVENBETTERSINE`).
|
||||
- **Bandpass Filter** — Ehlers bandpass filter: isolates a frequency band around the dominant cycle (`BANDPASS`).
|
||||
- **Adaptive CCI** — Adaptive CCI: efficiency-ratio-adaptive CCI on typical price (`ADAPTIVECCI`).
|
||||
- **Universal Oscillator** — Ehlers Universal Oscillator: SuperSmoother-based normalized cycle oscillator (`UNIVERSALOSC`).
|
||||
- **Adaptive RSI** — Adaptive RSI: dominant-cycle-tuned RSI length (Ehlers) (`ADAPTIVERSI`).
|
||||
- **Correlation Trend Indicator** — Ehlers Correlation Trend Indicator: Pearson correlation of price vs time (`CTI`).
|
||||
- **Trendflex** — Ehlers Trendflex: trend-following companion to Reflex (`TRENDFLEX`).
|
||||
- **Reflex** — Ehlers Reflex: trend-cycle oscillator measuring slope-adjusted displacement (`REFLEX`).
|
||||
- **Highpass Filter** — Ehlers highpass filter: removes low-frequency trend, leaving cyclic component (`HIGHPASS`).
|
||||
|
||||
## [0.6.4] - 2026-06-07
|
||||
- **Kendall Tau** — Kendall rank correlation (tau-b) over a rolling window of paired observations (`KENDALLTAU`).
|
||||
- **Sample Entropy** — Sample entropy: regularity/complexity of a rolling series (Richman-Moorman) (`SAMPLEENT`).
|
||||
- **Shannon Entropy** — Shannon entropy of a rolling value distribution over fixed bins (`SHANNONENT`).
|
||||
- **Rolling Min-Max Scaler** — Rolling min-max scaler mapping the latest value to 0..1 over a rolling window (`ROLLINGMINMAX`).
|
||||
- **Jarque-Bera** — Jarque-Bera normality test statistic over a rolling window (`JARQUEBERA`).
|
||||
|
||||
## [0.6.3] - 2026-06-07
|
||||
- **Volume-Weighted MACD** — Volume-Weighted MACD: MACD computed on VWMA instead of EMA, with signal line and histogram (`VWMACD`).
|
||||
- **Better Volume** — Better Volume (VSA): classifies volume against bar spread to surface effort/result imbalance (`BETTERVOL`).
|
||||
- **Intraday Intensity Index** — Intraday Intensity Index: volume weighted by close position within the bar range (`INTRADAYINT`).
|
||||
- **Trade Volume Index** — Trade Volume Index: accumulates volume by tick direction past a min-tick threshold (distinct from TSV) (`TRADEVOLIDX`).
|
||||
- **Twiggs Money Flow** — Twiggs Money Flow: volume-weighted accumulation using true range and Wilder smoothing (distinct from CMF) (`TWIGGSMF`).
|
||||
- **Williams Accumulation/Distribution** — Williams Accumulation/Distribution: cumulative price-direction accumulator (distinct from Chaikin A/D) (`WILLIAMSAD`).
|
||||
- **Volume RSI** — Volume RSI: Wilder-style RSI computed on signed volume flow (`VOLUMERSI`).
|
||||
|
||||
## [0.6.2] - 2026-06-07
|
||||
- **Modified MA Stop** — Modified MA Stop — SMMA-ratcheted trailing stop with directional flip (`MODIFIED_MA_STOP`).
|
||||
- **Time-Based Stop** — Time-Based Stop — bar-count timer that fires after a fixed holding period (`TIME_BASED_STOP`).
|
||||
@@ -1316,7 +1443,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
optional Binance live feed.
|
||||
- Bindings for Python, Node.js, and WebAssembly.
|
||||
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.6.2...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.7.7...HEAD
|
||||
[0.7.7]: https://github.com/wickra-lib/wickra/compare/v0.7.6...v0.7.7
|
||||
[0.7.6]: https://github.com/wickra-lib/wickra/compare/v0.7.5...v0.7.6
|
||||
[0.7.5]: https://github.com/wickra-lib/wickra/compare/v0.7.4...v0.7.5
|
||||
[0.7.4]: https://github.com/wickra-lib/wickra/compare/v0.7.3...v0.7.4
|
||||
[0.7.3]: https://github.com/wickra-lib/wickra/compare/v0.7.2...v0.7.3
|
||||
[0.7.2]: https://github.com/wickra-lib/wickra/compare/v0.7.1...v0.7.2
|
||||
[0.7.1]: https://github.com/wickra-lib/wickra/compare/v0.7.0...v0.7.1
|
||||
[0.7.0]: https://github.com/wickra-lib/wickra/compare/v0.6.9...v0.7.0
|
||||
[0.6.9]: https://github.com/wickra-lib/wickra/compare/v0.6.8...v0.6.9
|
||||
[0.6.8]: https://github.com/wickra-lib/wickra/compare/v0.6.7...v0.6.8
|
||||
[0.6.7]: https://github.com/wickra-lib/wickra/compare/v0.6.6...v0.6.7
|
||||
[0.6.6]: https://github.com/wickra-lib/wickra/compare/v0.6.5...v0.6.6
|
||||
[0.6.5]: https://github.com/wickra-lib/wickra/compare/v0.6.4...v0.6.5
|
||||
[0.6.4]: https://github.com/wickra-lib/wickra/compare/v0.6.3...v0.6.4
|
||||
[0.6.3]: https://github.com/wickra-lib/wickra/compare/v0.6.2...v0.6.3
|
||||
[0.6.2]: https://github.com/wickra-lib/wickra/compare/v0.6.1...v0.6.2
|
||||
[0.6.1]: https://github.com/wickra-lib/wickra/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://github.com/wickra-lib/wickra/compare/v0.5.9...v0.6.0
|
||||
|
||||
+9
-1
@@ -21,6 +21,9 @@ licensed as above, without any additional terms or conditions.
|
||||
| `bindings/python` | PyO3 bindings (`wickra` on PyPI). |
|
||||
| `bindings/node` | napi-rs bindings (`wickra` on npm). |
|
||||
| `bindings/wasm` | wasm-bindgen bindings (`wickra-wasm` on npm). |
|
||||
| `bindings/c` | C ABI — `cdylib` + `staticlib` + generated `include/wickra.h`. The hub for C / C++ and any C-capable language. |
|
||||
| `bindings/csharp` | .NET binding over the C ABI (`Wickra` on NuGet) — `[LibraryImport]` P/Invoke generated from `wickra.h`. |
|
||||
| `bindings/go` | Go binding over the C ABI via cgo (module tag `bindings/go/vX.Y.Z`) — wrappers generated from `wickra.h`. |
|
||||
| `examples/` | Runnable examples. |
|
||||
| `docs/` | Pointer to the documentation site (docs.wickra.org); the docs live in the `wickra-lib/wickra-docs` repo. |
|
||||
|
||||
@@ -102,7 +105,12 @@ installed. Dependabot also keeps the `.github/requirements` pins current.
|
||||
- **Streaming parity.** An indicator's `batch` output must equal the sequence
|
||||
of `update` calls.
|
||||
- **Bindings.** A change to a public indicator API must be mirrored across the
|
||||
Python, Node, and WASM bindings, including their type stubs / `.d.ts`.
|
||||
Python, Node, and WASM bindings, including their type stubs / `.d.ts`. The C ABI
|
||||
(`bindings/c`) is generated from the core, so regenerate it from the core and
|
||||
commit `src/lib.rs` + `include/wickra.h`. The C# binding (`bindings/csharp`) is
|
||||
generated from `wickra.h`, so regenerate and commit its `Generated/*.g.cs` too.
|
||||
The Go binding (`bindings/go`) is likewise generated from `wickra.h`, so
|
||||
regenerate and commit `indicators_gen.go` (`gofmt`-clean).
|
||||
- **Docs.** Update the relevant page on the
|
||||
[documentation site](https://docs.wickra.org) and the
|
||||
`README.md` when behaviour or the public API changes. The docs live in
|
||||
|
||||
Generated
+15
-8
@@ -1944,7 +1944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.6.2"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1955,7 +1955,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-bench"
|
||||
version = "0.6.2"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"kand",
|
||||
@@ -1965,9 +1965,16 @@ dependencies = [
|
||||
"yata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-c"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"wickra-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.6.2"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1977,7 +1984,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.6.2"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1994,7 +2001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-examples"
|
||||
version = "0.6.2"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -2004,7 +2011,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.6.2"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -2014,7 +2021,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.6.2"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -2023,7 +2030,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.6.2"
|
||||
version = "0.7.7"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+3
-2
@@ -7,13 +7,14 @@ members = [
|
||||
"bindings/python",
|
||||
"bindings/wasm",
|
||||
"bindings/node",
|
||||
"bindings/c",
|
||||
"examples/rust",
|
||||
"crates/wickra-bench",
|
||||
]
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.2"
|
||||
version = "0.7.7"
|
||||
authors = ["kingchenc <support@wickra.org>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
@@ -25,7 +26,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
||||
categories = ["finance", "mathematics", "science"]
|
||||
|
||||
[workspace.dependencies]
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.6.2" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.7.7" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
<p align="center">
|
||||
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=440" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=514" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
</p>
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/releases/latest)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](#license)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
|
||||
[](https://www.bestpractices.dev/projects/13094)
|
||||
[](https://github.com/wickra-lib/wickra/attestations)
|
||||
[](https://docs.wickra.org)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/releases/latest)
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](https://www.nuget.org/packages/Wickra)
|
||||
[](#license)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
|
||||
[](https://www.bestpractices.dev/projects/13094)
|
||||
[](https://github.com/wickra-lib/wickra/attestations)
|
||||
[](https://docs.wickra.org)
|
||||
|
||||
**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
|
||||
native bindings for Python, Node.js and WebAssembly, plus a C ABI that C, C++,
|
||||
C# / .NET, Go and any other C-capable language links against. 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
|
||||
@@ -46,9 +48,12 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
|
||||
- **Quickstarts** — [Rust](https://docs.wickra.org/Quickstart-Rust),
|
||||
[Python](https://docs.wickra.org/Quickstart-Python),
|
||||
[Node](https://docs.wickra.org/Quickstart-Node),
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM),
|
||||
[C](https://docs.wickra.org/Quickstart-C),
|
||||
[C#](https://docs.wickra.org/Quickstart-CSharp),
|
||||
[Go](https://docs.wickra.org/Quickstart-Go).
|
||||
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
|
||||
every one of the 440 indicators; start at the
|
||||
every one of the 514 indicators; start at the
|
||||
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
||||
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
||||
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
||||
@@ -58,19 +63,46 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
|
||||
[TA-Lib migration](https://docs.wickra.org/TA-Lib-Migration),
|
||||
[FAQ](https://docs.wickra.org/FAQ).
|
||||
|
||||
## Why Wickra exists
|
||||
## Why Wickra
|
||||
|
||||
Wickra started as a personal itch. The existing TA libraries never quite fit the
|
||||
projects I was building, so I decided to build one from the ground up — partly to
|
||||
learn, partly because I genuinely enjoy taking something that already exists and
|
||||
trying to do it differently (and, ideally, better). It's open source because the
|
||||
useful version of that itch is the one other people can build on too.
|
||||
Most TA libraries are fast, *or* multi-language, *or* broad. Wickra refuses to
|
||||
pick. It's the streaming-first engine built for the workload the others treat as
|
||||
an afterthought — **live, tick-by-tick data** — without giving up the breadth of
|
||||
a full batch library, and without making you reimplement your indicators four
|
||||
times to get there.
|
||||
|
||||
Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
|
||||
- **The biggest streaming-native catalogue, period.** 514 indicators across 24
|
||||
families — candlesticks, harmonic & chart patterns, market profile, market
|
||||
breadth, Renko/Kagi/Point&Figure bars, Ehlers DSP cycles, risk/performance
|
||||
metrics — every single one updating in **O(1) per tick**. TA-Lib ships ~150 and
|
||||
none of them stream.
|
||||
- **One Rust core, five first-class targets.** Native **Python · Node.js ·
|
||||
WebAssembly · Rust** plus a **C ABI** for C / C++, C# / .NET, Go and any other C-capable language —
|
||||
identical math, identical results, zero per-language reimplementation and zero
|
||||
GIL bottleneck.
|
||||
- **Correct by construction, not by hope.** Every `update` validates its input,
|
||||
runs a real warmup, and returns an `Option` so a single bad tick can't silently
|
||||
poison state. `batch == streaming` is **bit-exact, fuzzed and 100 %-line-covered
|
||||
for all 514 indicators**.
|
||||
- **Orders of magnitude faster where it counts.** In streaming Wickra is **11–56×**
|
||||
faster than the only other incremental peer and **thousands of times** faster
|
||||
than recompute-on-every-tick libraries. On batch it wins several rows outright
|
||||
and trades the simple recurrences (SMA, EMA, MACD) for its guarantees — and
|
||||
the losses are shown, not hidden.
|
||||
- **Install in one line, anywhere.** `pip install wickra` / `npm install wickra` —
|
||||
precompiled wheels and binaries, **no C toolchain, none of TA-Lib's setup pain**.
|
||||
macOS · Linux · Windows.
|
||||
- **Batteries included.** Indicator chaining, a streaming OHLCV CSV reader, and a
|
||||
live Binance kline feed ship in the box.
|
||||
- **Truly permissive.** **MIT OR Apache-2.0** — drop it straight into commercial
|
||||
and closed-source work.
|
||||
|
||||
Every other library forces one of those compromises. Wickra doesn't:
|
||||
|
||||
| Library | Install | Streaming | Languages | Indicators | Active |
|
||||
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
|
||||
| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **423** | **yes** |
|
||||
| **★ Wickra**| **clean** | **yes, O(1)** | **Rust · Python · Node · WASM** | **514** | **yes** |
|
||||
| | | | **C · C# · Go** | | |
|
||||
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
|
||||
| ta-rs | clean | yes | Rust only | ~30 | stale |
|
||||
| yata | clean | partial | Rust only | ~35 | yes |
|
||||
@@ -79,116 +111,31 @@ Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
|
||||
| finta | clean | no | Python | ~80 | stale |
|
||||
| talipp | clean | yes | Python | ~40 | yes |
|
||||
|
||||
Wickra's edge is **breadth with reach**: 440 indicators that all update in O(1)
|
||||
per tick and ship natively to Python, Node.js, WebAssembly and Rust from a
|
||||
single engine.
|
||||
Broad, multi-language, streaming-native **and** honest about its trade-offs — at
|
||||
the same time. That's the combination no one else ships.
|
||||
|
||||
**On speed — and why Wickra isn't the fastest.** It deliberately isn't. The
|
||||
leaner Rust crates (kand, ta-rs) win several of the micro-benchmarks below, and
|
||||
those losses are shown rather than hidden. The gap is a *choice*, not a ceiling:
|
||||
every `update` validates its input, runs a real warmup before it emits a value,
|
||||
and returns an `Option` so a single bad tick can't silently poison the state.
|
||||
ta-rs, by contrast, hands back a bare `f64` from the first tick with no
|
||||
validation. If Wickra threw all of that away — raw `f64` out, no checks, no
|
||||
warmup contract — it would match or beat the leanest crate on every row. It
|
||||
keeps the guarantees instead, and still wins RSI, Bollinger and ATR against kand.
|
||||
What no other library matches is the *combination*: catalogue size, native O(1)
|
||||
streaming, NaN-safety, and four first-class language targets at once.
|
||||
## Why Wickra exists
|
||||
|
||||
Wickra started as a personal itch. The existing TA libraries never quite fit the
|
||||
projects I was building, so I decided to build one from the ground up — partly to
|
||||
learn, partly because I genuinely enjoy taking something that already exists and
|
||||
trying to do it differently (and, ideally, better). It's open source because the
|
||||
useful version of that itch is the one other people can build on too.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Three comparisons, split by layer and mode. Read them as **relative** speedups
|
||||
on identical input — absolute µs depend on CPU, memory clock and OS scheduler,
|
||||
not a universal contract.
|
||||
Wickra updates every indicator in **O(1)** per tick. In **streaming** — the
|
||||
workload it is built for — it is **11–56× faster** than the only other incremental
|
||||
peer and **thousands of times** faster than recompute-on-every-tick libraries.
|
||||
**Batch** is competitive: it wins several rows outright and trades a few µs
|
||||
elsewhere for `None`-warmup, NaN-safety and bit-exact `batch == streaming`.
|
||||
|
||||
- **Reproduced on:** Windows 11 Pro 26200, AMD Ryzen 9 9950X, 64 GB DDR5,
|
||||
Rust 1.92 (release: `lto = "fat"`, `codegen-units = 1`), Python 3.12.
|
||||
- **Reproduce yourself:**
|
||||
- Rust core vs Rust crates: `cargo bench -p wickra-bench`
|
||||
- Python vs Python libs: `pip install -e bindings/python[bench]` then
|
||||
`python -m benchmarks.compare_libraries` (auto-detects installed peers).
|
||||
|
||||
### 1. Rust core vs the other Rust TA crates
|
||||
|
||||
Like-for-like, no language-binding overhead, over a 50 000-bar series (µs for
|
||||
the whole series, lower = faster). This is the honest engine comparison —
|
||||
Wickra wins some and loses some, and both are shown.
|
||||
|
||||
**Streaming** (one value fed per `update`):
|
||||
|
||||
| Indicator | **★ Wickra** | kand | ta-rs | yata |
|
||||
|------------------|------------------:|-----:|------:|-----:|
|
||||
| SMA(20) | 50 | 38 | 47 | 38 |
|
||||
| EMA(20) | 154 | 69 | 56 | 69 |
|
||||
| RSI(14) | 164 | 216 | 74 | — |
|
||||
| MACD(12, 26, 9) | 275 | 143 | 66 | — |
|
||||
| Bollinger(20, 2) | **128 ★** | 248 | 168 | — |
|
||||
| ATR(14) | 152 | 166 | 61 | — |
|
||||
|
||||
**Batch** (whole series at once). Only Wickra and kand expose a batch API;
|
||||
ta-rs and yata are streaming-only.
|
||||
|
||||
| Indicator | **★ Wickra** | kand |
|
||||
|------------------|------------------:|-----:|
|
||||
| SMA(20) | 82 | 42 |
|
||||
| EMA(20) | 159 | 74 |
|
||||
| RSI(14) | **253 ★** | 274 |
|
||||
| MACD(12, 26, 9) | 681 | 283 |
|
||||
| Bollinger(20, 2) | **445 ★** | 462 |
|
||||
| ATR(14) | 175 | 173 |
|
||||
|
||||
ta-rs is the per-indicator speed champion on almost every row — it returns a
|
||||
bare `f64` with no warmup state and no input validation, trading away the
|
||||
`None`-warmup and NaN-safety semantics Wickra keeps. Against kand, Wickra wins
|
||||
streaming RSI, Bollinger and ATR (and batch RSI + Bollinger); Bollinger is the
|
||||
one row where Wickra is the outright fastest of all four. The leaner crates
|
||||
still win the pure recurrences (EMA, MACD) and SMA. yata exposes only SMA/EMA as
|
||||
raw-value methods, so its other rows are omitted rather than faked.
|
||||
|
||||
### 2. Python vs the Python TA ecosystem — batch
|
||||
|
||||
Full pass over a 20 000-bar series, µs/op (lower = faster). **★** per row.
|
||||
|
||||
| Indicator | **★ Wickra** | finta | TA-Lib | tulipy |
|
||||
|------------------|------------------:|---------------------|--------|--------|
|
||||
| SMA(20) | **59.6 ★** | 354.2 (5.9× slower) | ⧗ | ⧗ |
|
||||
| EMA(20) | **88.4 ★** | 309.3 (3.5× slower) | ⧗ | ⧗ |
|
||||
| RSI(14) | **77.3 ★** | 1 283 (16.6× slower)| ⧗ | ⧗ |
|
||||
| MACD(12, 26, 9) | **116.4 ★** | 529.5 (4.6× slower) | ⧗ | ⧗ |
|
||||
| Bollinger(20, 2) | **146.0 ★** | 1 246 (8.5× slower) | ⧗ | ⧗ |
|
||||
| ATR(14) | **135.8 ★** | 3 812 (28× slower) | ⧗ | ⧗ |
|
||||
|
||||
> ⧗ = published by the CI Linux job. TA-Lib and tulipy ship C extensions that
|
||||
> don't build cleanly on every desktop, so their canonical numbers come from the
|
||||
> `cross-library-bench` workflow rather than this local table. pandas-ta needs
|
||||
> Python ≥ 3.12 and isn't in the 3.11 CI matrix. The script auto-detects
|
||||
> whichever peers are installed in your environment.
|
||||
|
||||
### 3. Python — streaming (per-tick latency)
|
||||
|
||||
Seed 5 000 bars, then feed ticks one at a time. talipp is the only Python peer
|
||||
with a true incremental API; batch-only libraries like TA-Lib must recompute the
|
||||
entire history on every tick — Wickra updates in O(1).
|
||||
|
||||
| Indicator | **★ Wickra (per tick)** | talipp (per tick) |
|
||||
|------------------|------------------------------:|-------------------------|
|
||||
| SMA(20) | **0.067 µs ★** | 0.63 µs (9.4× slower) |
|
||||
| EMA(20) | **0.051 µs ★** | 0.63 µs (12.2× slower) |
|
||||
| RSI(14) | **0.053 µs ★** | 1.00 µs (19.1× slower) |
|
||||
| MACD(12, 26, 9) | **0.071 µs ★** | 3.64 µs (51.5× slower) |
|
||||
| Bollinger(20, 2) | **0.085 µs ★** | 4.87 µs (57.2× slower) |
|
||||
|
||||
Run the suite yourself:
|
||||
|
||||
```bash
|
||||
cargo bench -p wickra-bench # Rust core vs kand / ta-rs / yata
|
||||
pip install -e bindings/python[bench] # Python peers
|
||||
python -m benchmarks.compare_libraries
|
||||
```
|
||||
Full tables (Rust + Python, streaming + batch) and how to reproduce them live in
|
||||
**[BENCHMARKS.md](BENCHMARKS.md)**.
|
||||
|
||||
## Indicators
|
||||
|
||||
440 streaming-first indicators across twenty-four families. Every one passes the
|
||||
514 streaming-first indicators across twenty-four families. Every one passes the
|
||||
`batch == streaming` equivalence test, reference-value tests, and reset
|
||||
semantics tests. Each has a per-indicator deep dive (formula, parameters,
|
||||
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
@@ -202,20 +149,20 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Volatility Cone |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands, Quartile Bands, Bomar Bands, Median Channel, Projection Bands, Projection Oscillator |
|
||||
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop, Kase DevStop, Elder SafeZone, ATR Ratchet, NRTR, Time-Based Stop, Modified MA Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Alt-Chart Bars | Renko (box-size bricks), Kagi (reversal-amount lines), Point & Figure (X/O columns) |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient, Jarque-Bera, Rolling Min-Max Scaler, Shannon Entropy, Sample Entropy, Kendall Tau |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline, Highpass Filter, Reflex, Trendflex, Correlation Trend Indicator, Adaptive RSI, Universal Oscillator, Adaptive CCI, Bandpass Filter, Even Better Sinewave, Autocorrelation Periodogram |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag, Central Pivot Range, Murrey Math Lines, Andrews Pitchfork, Volume-Weighted Support/Resistance, Pivot Reversal |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level, TD Camouflage, TD Clop, TD Clopwin, TD Propulsion, TD Trap, TD D-Wave, TD Moving Averages |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi, Heikin-Ashi Oscillator, Three Line Break, Smoothed Heikin-Ashi, Equivolume, CandleVolume |
|
||||
| Alt-Chart Bars | Renko (box-size bricks), Kagi (reversal-amount lines), Point & Figure (X/O columns), Range, Tick, Volume, Dollar, Imbalance, Run, Three-Line Break |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow, Tristar, Harami Cross, Tower Top/Bottom, Dumpling Top, New Price Lines, Frying Pan Bottom |
|
||||
| Chart Patterns | Double Top / Bottom, Triple Top / Bottom, Head and Shoulders, Triangle (asc/desc/sym), Wedge (rising/falling), Flag / Pennant, Rectangle / Range, Cup and Handle |
|
||||
| Harmonic Patterns | AB=CD, Gartley, Butterfly, Bat, Crab, Shark, Cypher, Three Drives |
|
||||
| Fibonacci | Fibonacci Retracement, Fibonacci Extension, Fibonacci Projection, Auto-Fibonacci, Golden Pocket, Fibonacci Confluence, Fibonacci Fan, Fibonacci Arcs, Fibonacci Channel, Fibonacci Time Zones |
|
||||
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint, Order Flow Imbalance, VPIN, Amihud Illiquidity, Roll Measure |
|
||||
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
|
||||
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint, Order Flow Imbalance, VPIN, Amihud Illiquidity, Roll Measure, Trade-Sign Autocorrelation, Hasbrouck Information Share |
|
||||
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread, Estimated Leverage Ratio, OI-to-Volume Ratio, Perpetual Premium Index, Funding-Implied APR, Open-Interest Momentum |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range, Naked POC, Single Prints, Profile Shape, High/Low Volume Nodes, Composite Profile |
|
||||
| Market Breadth | Advance/Decline Line, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
| Seasonality & Session | Session VWAP, Session High/Low, Session Range, Average Daily Range, Overnight Gap, Overnight/Intraday Return, Turn-of-Month, Seasonal Z-Score, Time-of-Day Return Profile, Day-of-Week Profile, Intraday Volatility Profile, Volume-by-Time Profile |
|
||||
@@ -226,8 +173,9 @@ as one column each. `Doji` is direction-less by default (`+1.0` / `0.0`);
|
||||
construct it in signed mode (`Doji::new().signed()`, `Doji(signed=True)`,
|
||||
`new Doji(true)`) for a dragonfly / gravestone `±1` reading.
|
||||
|
||||
Adding a new indicator means implementing one trait in Rust; all four bindings
|
||||
inherit it automatically.
|
||||
Adding a new indicator means implementing one trait in Rust; every binding
|
||||
inherits it automatically (the C ABI — and the C# and Go bindings generated from
|
||||
it — regenerate from the core).
|
||||
|
||||
## Languages
|
||||
|
||||
@@ -237,12 +185,16 @@ inherit it automatically.
|
||||
| Node.js (napi-rs) | `npm install wickra` | `examples/node/backtest.js` |
|
||||
| Browser / WASM | `npm install wickra-wasm` | `examples/wasm/index.html` |
|
||||
| Rust | `cargo add wickra` | `examples/rust/src/bin/backtest.rs` |
|
||||
| C / C++ (C ABI) | header + library, see [`bindings/c`](bindings/c) | `examples/c/streaming.c` |
|
||||
| C# / .NET (C ABI) | `dotnet add package Wickra`, see [`bindings/csharp`](bindings/csharp) | `examples/csharp/streaming` |
|
||||
| Go (cgo, C ABI) | `go get github.com/wickra-lib/wickra/bindings/go`, see [`bindings/go`](bindings/go) | `examples/go/streaming` |
|
||||
|
||||
Each binding ships several runnable examples (streaming, backtest, live feed);
|
||||
[`examples/README.md`](examples/README.md) is the full cross-language index.
|
||||
|
||||
The wickra-core crate is `unsafe`-forbidden, so every binding inherits a
|
||||
memory-safe implementation.
|
||||
The wickra-core crate is `unsafe`-forbidden, so the native bindings are
|
||||
memory-safe end to end. The C ABI runs the same safe core; only its thin FFI
|
||||
boundary uses `unsafe`, and the caller owns handle lifetimes (`_new` / `_free`).
|
||||
|
||||
## Rust API
|
||||
|
||||
@@ -297,20 +249,26 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 440 indicators
|
||||
│ ├── wickra-core/ core engine + all 514 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
|
||||
├── bindings/
|
||||
│ ├── python/ PyO3 + maturin (publishes on PyPI)
|
||||
│ ├── node/ napi-rs (publishes on npm)
|
||||
│ └── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
||||
│ ├── wasm/ wasm-bindgen (browsers, bundlers, Node)
|
||||
│ ├── c/ C ABI (cdylib + staticlib) + generated include/wickra.h
|
||||
│ ├── csharp/ .NET binding over the C ABI (publishes on NuGet)
|
||||
│ └── go/ Go binding over the C ABI via cgo (module tag)
|
||||
├── examples/ examples/README.md indexes every language
|
||||
│ ├── data/ real BTCUSDT OHLCV datasets, one per timeframe
|
||||
│ ├── rust/ Rust workspace member (`wickra-examples`)
|
||||
│ ├── python/ backtest, live trading, parallel assets, multi-tf
|
||||
│ ├── node/ streaming, backtest, live trading (load `wickra`)
|
||||
│ └── wasm/ browser demo for `wickra-wasm`
|
||||
│ ├── wasm/ browser demo for `wickra-wasm`
|
||||
│ ├── c/ C smoke + streaming, C++ RAII wrapper
|
||||
│ ├── csharp/ streaming, backtest, strategies (load `Wickra`)
|
||||
│ └── go/ streaming, backtest, strategies (cgo binding)
|
||||
└── .github/workflows/ CI and release pipelines
|
||||
```
|
||||
|
||||
@@ -338,6 +296,18 @@ 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
|
||||
|
||||
# C ABI (cdylib + staticlib + generated header)
|
||||
cargo build -p wickra-c --release
|
||||
cmake -S examples/c -B examples/c/build -DWICKRA_LIB_DIR="$PWD/target/release"
|
||||
cmake --build examples/c/build && ctest --test-dir examples/c/build --output-on-failure
|
||||
|
||||
# C# / .NET binding (requires the .NET 8 SDK; links the C ABI above)
|
||||
dotnet test bindings/csharp/Wickra.Tests/Wickra.Tests.csproj
|
||||
|
||||
# Go binding (requires a C compiler for cgo; links the C ABI above)
|
||||
cp target/release/libwickra.so bindings/go/lib/ # .dylib on macOS, wickra.dll on Windows
|
||||
cd bindings/go && go test ./...
|
||||
```
|
||||
|
||||
## Testing
|
||||
@@ -357,6 +327,8 @@ Every layer is covered; run the suites with the commands in
|
||||
values across all indicators.
|
||||
- `bindings/wasm`: `wasm-bindgen-test` cases for constructors, equivalence,
|
||||
and reference values.
|
||||
- `bindings/go`: `go test` cases covering one indicator per FFI archetype
|
||||
(scalar/batch, multi-output, bars, profile, array input), reset, and lifecycle.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+3
-2
@@ -21,8 +21,9 @@ minor releases; breaking changes are called out in the changelog.
|
||||
versioning stability for a 1.0 release.
|
||||
- **Performance.** Keep per-tick updates O(1) and maintain the benchmark suite;
|
||||
investigate further allocation and cache improvements.
|
||||
- **Bindings parity.** Keep the Python, Node.js and WebAssembly bindings in
|
||||
lockstep with the Rust core, including type stubs and platform coverage.
|
||||
- **Bindings parity.** Keep the Python, Node.js and WebAssembly bindings — plus
|
||||
the C ABI and the C# / .NET and Go bindings generated from it — in lockstep with the
|
||||
Rust core, including type stubs and platform coverage.
|
||||
- **Documentation.** Maintain a deep-dive page per indicator on
|
||||
<https://docs.wickra.org>, plus quickstarts and cookbook material.
|
||||
- **Project health.** Maintain test coverage, static and dynamic analysis,
|
||||
|
||||
+5
-1
@@ -58,7 +58,11 @@ artifacts, and (4) a healthy dependency supply chain.
|
||||
|
||||
- *Memory safety* — the core and all bindings are written in Rust. The crates
|
||||
forbid or minimise `unsafe`, so the compiler guarantees memory and thread
|
||||
safety for the indicator logic.
|
||||
safety for the indicator logic. The one exception is the C ABI
|
||||
([`bindings/c`](bindings/c)), whose thin FFI shim is necessarily `unsafe`
|
||||
because it dereferences caller-supplied pointers; it adds no indicator logic,
|
||||
validates every handle for NULL, and never lets a panic cross the boundary, so
|
||||
the safe core's guarantees still cover all computation.
|
||||
- *Input robustness* — every indicator validates its parameters and rejects
|
||||
non-finite inputs at construction; behaviour on edge cases (flat markets,
|
||||
warmup, reset) is pinned by unit tests, and the public update paths are
|
||||
|
||||
+2
-2
@@ -7,8 +7,8 @@ Thanks for using Wickra! Here is where to get help, depending on what you need.
|
||||
Most questions are answered in the documentation:
|
||||
|
||||
- **Docs site:** <https://docs.wickra.org> — quickstarts for Rust, Python,
|
||||
Node.js and WebAssembly, a per-indicator reference, warmup periods, the data
|
||||
layer, and an FAQ.
|
||||
Node.js, WebAssembly, C, C# and Go, a per-indicator reference, warmup periods, the
|
||||
data layer, and an FAQ.
|
||||
- **README:** <https://github.com/wickra-lib/wickra#readme> — installation and a
|
||||
quick overview.
|
||||
- **API docs (Rust):** <https://docs.rs/wickra>.
|
||||
|
||||
+5
-2
@@ -3,8 +3,10 @@
|
||||
This document describes Wickra's attack surface and the threats considered,
|
||||
together with their mitigations. It complements the security assurance case in
|
||||
[`SECURITY.md`](SECURITY.md). Wickra is a computational technical-analysis
|
||||
library (a Rust core with Python, Node.js and WebAssembly bindings), not a
|
||||
network service or trading system; the attack surface is correspondingly small.
|
||||
library (a Rust core with Python, Node.js and WebAssembly bindings plus a C ABI
|
||||
and the .NET and Go bindings built on it),
|
||||
not a network service or trading system; the attack surface is correspondingly
|
||||
small.
|
||||
|
||||
## Assets
|
||||
|
||||
@@ -31,6 +33,7 @@ network service or trading system; the attack surface is correspondingly small.
|
||||
| Threat | Mitigation |
|
||||
| --- | --- |
|
||||
| Memory-safety exploit (buffer overflow, UAF) via crafted input | Pure safe Rust; `unsafe` is forbidden/minimised, so the compiler precludes these classes. |
|
||||
| Misuse of the C ABI FFI boundary (invalid/dangling handle, undersized batch buffer) | The C ABI (`bindings/c`) is the sole `unsafe` surface. Its shim adds no logic, NULL-checks every handle (returning `NaN`/no-op), writes only into caller-sized buffers, and catches panics so none cross the boundary. A caller passing a non-NULL but dangling pointer is undefined behaviour by C's own contract — out of scope, the same as any C library. |
|
||||
| Denial of service via malformed/degenerate input (NaN, infinities, extreme magnitudes) | Indicators reject non-finite inputs and validate parameters at construction; update paths are exercised by coverage-guided fuzzing and unit tests for edge cases. |
|
||||
| Silently incorrect results | 100% line coverage on the core crate; reference-value tests against known-good sources; streaming/batch parity tests. |
|
||||
| Integer overflow / panics | `clippy::pedantic` with `-D warnings`; debug assertions and overflow checks enabled in test/fuzz builds. |
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "wickra-c"
|
||||
description = "C ABI (cdylib + staticlib) for the Wickra streaming-first technical indicators library — the hub every C-capable language (C, C++, Go, C#, Java, R) links against."
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme = "README.md"
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "wickra"
|
||||
crate-type = ["cdylib", "staticlib"]
|
||||
|
||||
# The C ABI inherently needs `unsafe` (raw pointers across the FFI boundary,
|
||||
# `#[export_name]` symbol control). The workspace forbids `unsafe_code`, so this
|
||||
# crate cannot inherit `workspace = true`; it mirrors every workspace lint and
|
||||
# only relaxes `unsafe_code` to `allow` (parity with how the proc-macro bindings
|
||||
# emit their unsafe). The Rust core stays `unsafe`-forbidden — this is the one
|
||||
# crate where the boundary lives.
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
missing_debug_implementations = "warn"
|
||||
unreachable_pub = "warn"
|
||||
unused_must_use = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
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"
|
||||
|
||||
[dependencies]
|
||||
wickra-core = { workspace = true }
|
||||
@@ -0,0 +1,80 @@
|
||||
# Wickra — C / C++
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/releases/latest)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
**Streaming-first technical indicators for C and C++. A prebuilt shared/static
|
||||
library plus a generated `wickra.h` — no system dependencies.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and any
|
||||
other C-capable language. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the **C ABI hub**: it compiles the
|
||||
core to a C-compatible shared/static library plus a generated header, so any
|
||||
C-capable language (C, C++, Go, C#, Java, R) links against one artifact instead
|
||||
of re-wrapping every indicator natively.
|
||||
|
||||
## Install
|
||||
|
||||
Grab the prebuilt header + library for your platform from the
|
||||
[GitHub releases](https://github.com/wickra-lib/wickra/releases) — each archive
|
||||
has `wickra.h`, the optional `wickra.hpp` C++ wrapper, and the shared/static
|
||||
library — or build from source:
|
||||
|
||||
```bash
|
||||
cargo build -p wickra-c --release
|
||||
# -> target/release/libwickra.{so,dylib} or wickra.dll (+ import lib) + a staticlib
|
||||
```
|
||||
|
||||
Then compile against the header and link the library
|
||||
(`cc app.c -I include -L lib -lwickra -lm -o app`).
|
||||
|
||||
## Quick start
|
||||
|
||||
```c
|
||||
#include "wickra.h"
|
||||
|
||||
struct Rsi *rsi = wickra_rsi_new(14); /* NULL on invalid params */
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
double v = wickra_rsi_update(rsi, prices[i]); /* NaN during warmup */
|
||||
if (v == v && v > 70.0) /* v == v is the NaN check */
|
||||
printf("overbought\n");
|
||||
}
|
||||
wickra_rsi_free(rsi); /* exactly once per _new */
|
||||
```
|
||||
|
||||
Every indicator is an opaque handle with the same five functions —
|
||||
`_new` / `_update` / `_batch` / `_reset` / `_free`. `update` is O(1); there is no
|
||||
RAII across the C boundary, so each `_new` needs exactly one `_free`, and every
|
||||
function is NULL-safe (a NULL handle yields `NaN` or a no-op, never a crash).
|
||||
Multi-output indicators (MACD, Bollinger, ADX, …) take a pointer to a `#[repr(C)]`
|
||||
struct and return a `bool`. The optional `wickra.hpp` wraps any handle in a
|
||||
move-only `wickra::Handle` for exception-safe C++ lifetimes.
|
||||
|
||||
## Documentation
|
||||
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (C quickstart, cookbook, TA-Lib migration): <https://docs.wickra.org/Quickstart-C>
|
||||
- **Runnable examples:** [`examples/c/`](https://github.com/wickra-lib/wickra/tree/main/examples/c)
|
||||
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus this
|
||||
C ABI hub that any C-capable language (C, C++, Go, C#, Java, R) links against —
|
||||
all exposing the same indicators from the shared Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
@@ -0,0 +1,20 @@
|
||||
language = "C"
|
||||
header = "/* Wickra C ABI — generated by cbindgen. Do not edit by hand. */"
|
||||
include_guard = "WICKRA_H"
|
||||
pragma_once = true
|
||||
# Wrap the declarations in `extern "C"` under __cplusplus so the header is usable
|
||||
# from C++ (the optional wickra.hpp RAII layer and any C++ consumer).
|
||||
cpp_compat = true
|
||||
tab_width = 4
|
||||
# Off: cbindgen copies the Rust struct/fn doc comments verbatim, and some core
|
||||
# indicator docs contain markdown (e.g. `**1/8**/**7/8**`) whose `*/` would close
|
||||
# the C block comment early and break the header. Usage docs live in the crate
|
||||
# README and examples; the header is a pure declaration contract.
|
||||
documentation = false
|
||||
|
||||
[parse]
|
||||
# Parse wickra-core too so the opaque indicator handle types (Sma, Ema, …) are
|
||||
# discovered and emitted as forward-declared opaque structs. Their fields are
|
||||
# never exposed — only `T *` handles cross the boundary.
|
||||
parse_deps = true
|
||||
include = ["wickra-core"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
// Optional C++ convenience layer over the Wickra C ABI (`wickra.h`).
|
||||
//
|
||||
// The C ABI hands out raw handles that must be released exactly once with the
|
||||
// matching `wickra_<ind>_free`. `wickra::Handle` wraps that in a move-only RAII
|
||||
// owner so the free happens automatically at scope exit:
|
||||
//
|
||||
// #include "wickra.hpp"
|
||||
//
|
||||
// wickra::Handle<Sma, wickra_sma_free> sma(wickra_sma_new(14));
|
||||
// if (sma) {
|
||||
// double v = wickra_sma_update(sma.get(), 42.0); // NaN during warmup
|
||||
// }
|
||||
// // sma is freed here
|
||||
//
|
||||
// This is header-only and adds no runtime cost beyond the C calls themselves.
|
||||
|
||||
#ifndef WICKRA_HPP
|
||||
#define WICKRA_HPP
|
||||
|
||||
#include "wickra.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace wickra {
|
||||
|
||||
/// Move-only RAII owner of a Wickra handle. `T` is the opaque indicator type and
|
||||
/// `Free` its `wickra_<ind>_free` function.
|
||||
template <typename T, void (*Free)(T *)>
|
||||
class Handle {
|
||||
public:
|
||||
explicit Handle(T *ptr) noexcept : ptr_(ptr) {}
|
||||
|
||||
~Handle() {
|
||||
if (ptr_ != nullptr) {
|
||||
Free(ptr_);
|
||||
}
|
||||
}
|
||||
|
||||
Handle(const Handle &) = delete;
|
||||
Handle &operator=(const Handle &) = delete;
|
||||
|
||||
Handle(Handle &&other) noexcept : ptr_(std::exchange(other.ptr_, nullptr)) {}
|
||||
|
||||
Handle &operator=(Handle &&other) noexcept {
|
||||
if (this != &other) {
|
||||
if (ptr_ != nullptr) {
|
||||
Free(ptr_);
|
||||
}
|
||||
ptr_ = std::exchange(other.ptr_, nullptr);
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
/// The raw handle, for passing to the `wickra_<ind>_*` functions.
|
||||
T *get() const noexcept { return ptr_; }
|
||||
|
||||
/// True if the handle is non-null (construction succeeded).
|
||||
explicit operator bool() const noexcept { return ptr_ != nullptr; }
|
||||
|
||||
private:
|
||||
T *ptr_;
|
||||
};
|
||||
|
||||
} // namespace wickra
|
||||
|
||||
#endif // WICKRA_HPP
|
||||
+44290
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
# .NET build output
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
|
||||
# NuGet packaging output
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
|
||||
# Native libraries staged for packaging (produced by the release pipeline from
|
||||
# the wickra-c-<triple>.tar.gz assets; never committed to source).
|
||||
Wickra/runtimes/
|
||||
@@ -0,0 +1,78 @@
|
||||
# Wickra — .NET
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://www.nuget.org/packages/Wickra)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
**Streaming-first technical indicators for .NET. `dotnet add package Wickra` —
|
||||
prebuilt native library, no system dependencies.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and any
|
||||
other C-capable language. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the .NET binding; it consumes the
|
||||
C ABI hub through `[LibraryImport]` P/Invoke and exposes all 514 streaming-first
|
||||
indicators as idiomatic `IDisposable` classes.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
dotnet add package Wickra
|
||||
```
|
||||
|
||||
The native library ships prebuilt per platform (Linux, macOS, Windows — x64 and
|
||||
arm64) under `runtimes/<rid>/native/`, selected automatically. There is nothing
|
||||
to compile. Targets .NET 8 and later.
|
||||
|
||||
## Quick start
|
||||
|
||||
```csharp
|
||||
using Wickra;
|
||||
|
||||
// Batch: run an indicator over a whole series (NaN at warmup positions).
|
||||
var prices = Enumerable.Range(0, 1000).Select(i => 100.0 + i * 0.1).ToArray();
|
||||
using var sma = new Sma(20);
|
||||
double[] values = sma.Batch(prices);
|
||||
|
||||
// Streaming: the same indicator, fed tick by tick in O(1).
|
||||
using var rsi = new Rsi(14);
|
||||
foreach (var price in liveFeed)
|
||||
{
|
||||
var value = rsi.Update(price); // NaN during warmup, no recomputation
|
||||
if (double.IsFinite(value) && value > 70)
|
||||
{
|
||||
Console.WriteLine("overbought");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Batch(prices)` and feeding the same prices through `Update()` produce identical
|
||||
values — the equivalence is enforced by the test suite. Multi-output indicators
|
||||
(MACD, Bollinger, ADX, …) return a nullable `record struct`, `null` while warming up.
|
||||
|
||||
## Documentation
|
||||
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in
|
||||
the main repository and documentation site:
|
||||
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/csharp/`](https://github.com/wickra-lib/wickra/tree/main/examples/csharp)
|
||||
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus a
|
||||
C ABI hub that any C-capable language (C, C++, Go, C#, Java, R) links against —
|
||||
all exposing the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes
|
||||
are deterministic transforms of the input data — they are not financial advice
|
||||
and do not predict the market. Any use in a live trading context is at your own
|
||||
risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
@@ -0,0 +1,144 @@
|
||||
using Wickra;
|
||||
using Xunit;
|
||||
|
||||
namespace Wickra.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One representative per FFI archetype, exercising every marshalling path the
|
||||
/// generator produces (scalar, candle, pairwise, multi-output, bars, profile,
|
||||
/// values-profile, array-input). Garbage marshalling surfaces as NaN, wild
|
||||
/// values, or crashes — so finite/sane assertions are the real check.
|
||||
/// </summary>
|
||||
public class ArchetypeTests
|
||||
{
|
||||
private static (double open, double high, double low, double close, double volume, long ts) Candle(int i)
|
||||
{
|
||||
var close = 100.0 + 10.0 * Math.Sin(i * 0.3);
|
||||
var open = 100.0 + 10.0 * Math.Sin((i - 1) * 0.3);
|
||||
var high = Math.Max(open, close) + 1.0;
|
||||
var low = Math.Min(open, close) - 1.0;
|
||||
return (open, high, low, close, 1_000.0, i * 60_000L);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Scalar_Ema_IsFiniteAfterWarmup()
|
||||
{
|
||||
using var ema = new Ema(3);
|
||||
double last = double.NaN;
|
||||
for (var i = 1; i <= 10; i++)
|
||||
{
|
||||
last = ema.Update(i);
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(last));
|
||||
Assert.InRange(last, 1.0, 10.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Candle_Atr_IsFinitePositive()
|
||||
{
|
||||
using var atr = new Atr(3);
|
||||
double last = double.NaN;
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
var (o, h, l, c, v, ts) = Candle(i);
|
||||
last = atr.Update(o, h, l, c, v, ts);
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(last));
|
||||
Assert.True(last > 0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pairwise_Beta_IsFinite()
|
||||
{
|
||||
using var beta = new Beta(5);
|
||||
double last = double.NaN;
|
||||
for (var i = 0; i < 30; i++)
|
||||
{
|
||||
var market = 100.0 + 10.0 * Math.Sin(i * 0.5);
|
||||
var asset = 50.0 + 6.0 * Math.Sin(i * 0.5 + 0.2);
|
||||
last = beta.Update(market, asset);
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(last));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiOutput_Adx_ReturnsFiniteStruct()
|
||||
{
|
||||
using var adx = new Adx(5);
|
||||
AdxOutput? result = null;
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
var (o, h, l, c, v, ts) = Candle(i);
|
||||
result = adx.Update(o, h, l, c, v, ts);
|
||||
}
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(double.IsFinite(result!.Value.Adx));
|
||||
Assert.True(double.IsFinite(result.Value.PlusDi));
|
||||
Assert.True(double.IsFinite(result.Value.MinusDi));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bars_DollarBars_EmitsBars()
|
||||
{
|
||||
using var bars = new DollarBars(5_000.0);
|
||||
var total = 0;
|
||||
for (var i = 0; i < 200; i++)
|
||||
{
|
||||
var (o, h, l, c, v, ts) = Candle(i);
|
||||
total += bars.Update(o, h, l, c, v, ts).Length;
|
||||
}
|
||||
|
||||
Assert.True(total > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Profile_VolumeProfile_ReturnsValues()
|
||||
{
|
||||
using var profile = new VolumeProfile(20, 8);
|
||||
VolumeProfileOutputScalars? result = null;
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
var (o, h, l, c, v, ts) = Candle(i);
|
||||
result = profile.Update(o, h, l, c, v, ts);
|
||||
}
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.NotNull(result!.Value.Values);
|
||||
Assert.True(result.Value.PriceLow <= result.Value.PriceHigh);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProfileValues_DayOfWeekProfile_NoCrash()
|
||||
{
|
||||
using var profile = new DayOfWeekProfile(0);
|
||||
double[]? result = null;
|
||||
for (var i = 0; i < 60; i++)
|
||||
{
|
||||
var close = 100.0 + 5.0 * Math.Sin(i * 0.2);
|
||||
// one day apart so the day-of-week buckets fill
|
||||
result = profile.Update(close, close + 1, close - 1, close, 1_000.0, i * 86_400_000L);
|
||||
}
|
||||
|
||||
if (result is not null)
|
||||
{
|
||||
Assert.All(result, v => Assert.True(double.IsFinite(v)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArrayInput_DepthSlope_IsFinite()
|
||||
{
|
||||
using var slope = new DepthSlope();
|
||||
ReadOnlySpan<double> bidPrice = stackalloc double[] { 99.0, 98.0, 97.0 };
|
||||
ReadOnlySpan<double> bidSize = stackalloc double[] { 10.0, 20.0, 30.0 };
|
||||
ReadOnlySpan<double> askPrice = stackalloc double[] { 101.0, 102.0, 103.0 };
|
||||
ReadOnlySpan<double> askSize = stackalloc double[] { 12.0, 22.0, 32.0 };
|
||||
|
||||
var result = slope.Update(bidPrice, bidSize, askPrice, askSize);
|
||||
Assert.True(double.IsFinite(result));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Wickra;
|
||||
using Xunit;
|
||||
|
||||
namespace Wickra.Tests;
|
||||
|
||||
public class SmaTests
|
||||
{
|
||||
[Fact]
|
||||
public void StreamingMatchesReference()
|
||||
{
|
||||
using var sma = new Sma(3);
|
||||
Assert.True(double.IsNaN(sma.Update(1)));
|
||||
Assert.True(double.IsNaN(sma.Update(2)));
|
||||
Assert.Equal(2.0, sma.Update(3), 9);
|
||||
Assert.Equal(3.0, sma.Update(4), 9);
|
||||
Assert.Equal(4.0, sma.Update(5), 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchMatchesStreaming()
|
||||
{
|
||||
using var sma = new Sma(3);
|
||||
var output = sma.Batch(new double[] { 1, 2, 3, 4, 5 });
|
||||
|
||||
Assert.True(double.IsNaN(output[0]));
|
||||
Assert.True(double.IsNaN(output[1]));
|
||||
Assert.Equal(2.0, output[2], 9);
|
||||
Assert.Equal(3.0, output[3], 9);
|
||||
Assert.Equal(4.0, output[4], 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetClearsState()
|
||||
{
|
||||
using var sma = new Sma(3);
|
||||
sma.Update(1);
|
||||
sma.Update(2);
|
||||
sma.Update(3);
|
||||
sma.Reset();
|
||||
Assert.True(double.IsNaN(sma.Update(10)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroPeriodThrows()
|
||||
{
|
||||
// Zero is rejected by the native constructor (returns NULL) -> ArgumentException;
|
||||
// a negative period is caught earlier by the wrapper guard.
|
||||
Assert.Throws<ArgumentException>(() => new Sma(0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Sma(-1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Wickra\Wickra.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<RootNamespace>Wickra</RootNamespace>
|
||||
<AssemblyName>Wickra</AssemblyName>
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>Wickra</PackageId>
|
||||
<Version>0.7.7</Version>
|
||||
<Authors>kingchenc</Authors>
|
||||
<Description>High-performance streaming technical-analysis indicators (514 indicators) for .NET, backed by the native Rust core via the Wickra C ABI.</Description>
|
||||
<PackageLicenseExpression>MIT OR Apache-2.0</PackageLicenseExpression>
|
||||
<PackageProjectUrl>https://github.com/wickra-lib/wickra</PackageProjectUrl>
|
||||
<RepositoryUrl>https://github.com/wickra-lib/wickra</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageTags>technical-analysis;indicators;trading;finance;streaming;ffi;native</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<!-- A managed package carrying per-RID native assets; not built per-RID itself. -->
|
||||
<IncludeBuildOutput>true</IncludeBuildOutput>
|
||||
<!-- NU5128: managed package carrying only per-RID native assets.
|
||||
CS1591: generated members are self-descriptive; hand-written API is documented. -->
|
||||
<NoWarn>$(NoWarn);NU5128;CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Supported native runtime identifiers. The release pipeline builds the C ABI
|
||||
per target triple and stages the libraries under
|
||||
Wickra/runtimes/<rid>/native/ before `dotnet pack`:
|
||||
win-x64 win-arm64 linux-x64 linux-arm64 osx-x64 osx-arm64
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<WickraRuntimeIdentifiers>win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64</WickraRuntimeIdentifiers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Native libraries are packed under runtimes/<rid>/native/ by the release pipeline,
|
||||
which unpacks the wickra-c-<triple>.tar.gz assets into the matching RID folders.
|
||||
For local development and tests the natives are resolved from the cargo target dir
|
||||
via WickraNative's DllImportResolver.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<None Include="runtimes/**/native/*" Pack="true" PackagePath="runtimes" Condition="Exists('runtimes')" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace Wickra;
|
||||
|
||||
/// <summary>
|
||||
/// Owns an opaque native indicator handle and releases it via the indicator's
|
||||
/// <c>_free</c> function. One generic handle type backs every indicator; the
|
||||
/// correct free routine is captured at construction time.
|
||||
/// </summary>
|
||||
internal sealed class WickraHandle : SafeHandleZeroOrMinusOneIsInvalid
|
||||
{
|
||||
private readonly Action<nint> _free;
|
||||
|
||||
internal WickraHandle(nint handle, Action<nint> free)
|
||||
: base(ownsHandle: true)
|
||||
{
|
||||
_free = free;
|
||||
SetHandle(handle);
|
||||
}
|
||||
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
_free(handle);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Wickra;
|
||||
|
||||
/// <summary>
|
||||
/// Native library resolution for the Wickra C ABI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When consumed as a NuGet package the native library ships under
|
||||
/// <c>runtimes/<rid>/native/</c> and the default runtime resolver finds it
|
||||
/// automatically. For local development (project reference against a cargo build)
|
||||
/// the resolver additionally walks up the directory tree to locate
|
||||
/// <c>target/release</c> or <c>target/debug</c>. Every candidate is validated to
|
||||
/// actually export the Wickra ABI before it is accepted, so an unrelated library
|
||||
/// of the same name cannot shadow the real one.
|
||||
/// </remarks>
|
||||
internal static class WickraNative
|
||||
{
|
||||
/// <summary>The library name passed to <c>[LibraryImport]</c>.</summary>
|
||||
internal const string LibraryName = "wickra";
|
||||
|
||||
// Any exported symbol works as a fingerprint; sma_new exists in every build.
|
||||
private const string SentinelSymbol = "wickra_sma_new";
|
||||
|
||||
[ModuleInitializer]
|
||||
internal static void Register()
|
||||
{
|
||||
NativeLibrary.SetDllImportResolver(typeof(WickraNative).Assembly, Resolve);
|
||||
}
|
||||
|
||||
private static nint Resolve(string libraryName, System.Reflection.Assembly assembly, DllImportSearchPath? searchPath)
|
||||
{
|
||||
if (libraryName != LibraryName)
|
||||
{
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
// 1. Default resolution (NuGet runtimes/ layout, app-local copies). Accept
|
||||
// only if it is genuinely the Wickra ABI; otherwise discard and fall through.
|
||||
if (NativeLibrary.TryLoad(libraryName, assembly, searchPath, out var handle))
|
||||
{
|
||||
if (Exports(handle))
|
||||
{
|
||||
return handle;
|
||||
}
|
||||
|
||||
NativeLibrary.Free(handle);
|
||||
}
|
||||
|
||||
// 2. Development fallback: locate the cargo build output.
|
||||
var fileName = NativeFileName();
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (var i = 0; i < 16 && dir is not null; i++)
|
||||
{
|
||||
foreach (var profile in new[] { "release", "debug" })
|
||||
{
|
||||
var candidate = Path.Combine(dir, "target", profile, fileName);
|
||||
if (File.Exists(candidate) && NativeLibrary.TryLoad(candidate, out var devHandle))
|
||||
{
|
||||
if (Exports(devHandle))
|
||||
{
|
||||
return devHandle;
|
||||
}
|
||||
|
||||
NativeLibrary.Free(devHandle);
|
||||
}
|
||||
}
|
||||
|
||||
dir = Path.GetDirectoryName(dir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
|
||||
}
|
||||
|
||||
return nint.Zero;
|
||||
}
|
||||
|
||||
private static bool Exports(nint handle) => NativeLibrary.TryGetExport(handle, SentinelSymbol, out _);
|
||||
|
||||
private static string NativeFileName()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return "wickra.dll";
|
||||
}
|
||||
|
||||
return OperatingSystem.IsMacOS() ? "libwickra.dylib" : "libwickra.so";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
# Wickra — Go
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://pkg.go.dev/github.com/wickra-lib/wickra/bindings/go)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
**Streaming-first technical indicators for Go, over the Wickra C ABI hub via cgo.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and
|
||||
any other C-capable language. Every indicator is an O(1) streaming state machine,
|
||||
so live trading bots and historical backtests share the exact same
|
||||
implementation. This package is the Go binding; it consumes the C ABI hub through
|
||||
cgo and exposes all 514 streaming-first indicators as idiomatic types.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
go get github.com/wickra-lib/wickra/bindings/go
|
||||
```
|
||||
|
||||
The binding uses cgo, so a C compiler is required, and it links against the
|
||||
prebuilt Wickra C ABI library. Build that library from the workspace and stage
|
||||
it under this package's `lib/` directory:
|
||||
|
||||
```bash
|
||||
cargo build -p wickra-c --release
|
||||
cp target/release/libwickra.so bindings/go/lib/ # Linux
|
||||
cp target/release/libwickra.dylib bindings/go/lib/ # macOS
|
||||
cp target/release/wickra.dll bindings/go/lib/ # Windows (also on PATH at run time)
|
||||
```
|
||||
|
||||
On Linux and macOS the library path is baked in via rpath; on Windows the DLL
|
||||
must be discoverable at run time (next to the executable or on `PATH`).
|
||||
|
||||
## Quick start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
wickra "github.com/wickra-lib/wickra/bindings/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Batch: run an indicator over a whole series (NaN at warmup positions).
|
||||
prices := make([]float64, 1000)
|
||||
for i := range prices {
|
||||
prices[i] = 100.0 + float64(i)*0.1
|
||||
}
|
||||
sma, _ := wickra.NewSma(20)
|
||||
defer sma.Close()
|
||||
values := sma.Batch(prices)
|
||||
|
||||
// Streaming: the same indicator, fed tick by tick in O(1).
|
||||
rsi, _ := wickra.NewRsi(14)
|
||||
defer rsi.Close()
|
||||
for _, price := range prices {
|
||||
value := rsi.Update(price) // NaN during warmup, no recomputation
|
||||
if value > 70 {
|
||||
fmt.Println("overbought")
|
||||
}
|
||||
}
|
||||
_ = values
|
||||
}
|
||||
```
|
||||
|
||||
`Batch(prices)` and feeding the same prices through `Update()` produce identical
|
||||
values — the equivalence is enforced by the test suite. Multi-output indicators
|
||||
(MACD, Bollinger, ADX, …) return `(Output, bool)`, with `false` while warming up.
|
||||
Every indicator owns a native handle freed by `Close()`; a finalizer is wired as
|
||||
a backstop, but call `Close()` (e.g. with `defer`) to release memory promptly.
|
||||
|
||||
## Documentation
|
||||
|
||||
The full indicator catalogue, guides, quickstarts, and API reference live in the
|
||||
main repository and documentation site:
|
||||
|
||||
- **Repository & full indicator list:** <https://github.com/wickra-lib/wickra>
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/go/`](https://github.com/wickra-lib/wickra/tree/main/examples/go)
|
||||
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus a
|
||||
C ABI hub that any C-capable language (C, C++, C#, Go, Java, R) links against —
|
||||
all exposing the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Wickra is an indicator toolkit, not a trading system. The values it computes are
|
||||
deterministic transforms of the input data — they are not financial advice and
|
||||
do not predict the market. Any use in a live trading context is at your own risk.
|
||||
The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
@@ -0,0 +1,3 @@
|
||||
module github.com/wickra-lib/wickra/bindings/go
|
||||
|
||||
go 1.23
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
# Prebuilt Wickra C ABI libraries are provisioned locally / in CI, not committed.
|
||||
*.so
|
||||
*.dylib
|
||||
*.dll
|
||||
*.a
|
||||
*.lib
|
||||
*.exp
|
||||
@@ -0,0 +1,25 @@
|
||||
// Package wickra provides idiomatic Go bindings for the Wickra
|
||||
// technical-analysis library over its C ABI hub.
|
||||
//
|
||||
// Each indicator is an opaque-handle type with a New<Indicator> constructor and
|
||||
// Update/Batch/Reset/Close methods. Handles are freed by Close and, as a
|
||||
// backstop, by a finalizer; call Close explicitly to release native memory
|
||||
// promptly. The binding links against the prebuilt Wickra C ABI library
|
||||
// (libwickra.so/.dylib or wickra.dll) staged under ./lib — see the package
|
||||
// README for how to provision it.
|
||||
package wickra
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -I${SRCDIR}/../c/include
|
||||
#cgo linux LDFLAGS: -L${SRCDIR}/lib -lwickra -Wl,-rpath,${SRCDIR}/lib
|
||||
#cgo darwin LDFLAGS: -L${SRCDIR}/lib -lwickra -Wl,-rpath,${SRCDIR}/lib
|
||||
#cgo windows LDFLAGS: -L${SRCDIR}/lib -l:wickra.dll
|
||||
#include "wickra.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrInvalidParams is returned by a New<Indicator> constructor when the native
|
||||
// constructor rejects the supplied parameters (for example a zero period).
|
||||
var ErrInvalidParams = errors.New("wickra: invalid indicator parameters")
|
||||
@@ -0,0 +1,151 @@
|
||||
package wickra
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// One indicator per FFI archetype, exercising the full New/Update/Batch/Reset/
|
||||
// Close surface against the real native library.
|
||||
|
||||
func TestScalarKnownValue(t *testing.T) {
|
||||
s, err := NewSma(3)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSma: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
var last float64
|
||||
for _, v := range []float64{1, 2, 3, 4, 5} {
|
||||
last = s.Update(v)
|
||||
}
|
||||
if math.Abs(last-4.0) > 1e-9 {
|
||||
t.Fatalf("sma(3) last = %v, want 4.0", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScalarBatchMatchesStreaming(t *testing.T) {
|
||||
input := []float64{1, 2, 3, 4, 5, 6, 7, 8}
|
||||
|
||||
stream, _ := NewSma(3)
|
||||
defer stream.Close()
|
||||
want := make([]float64, len(input))
|
||||
for i, v := range input {
|
||||
want[i] = stream.Update(v)
|
||||
}
|
||||
|
||||
batchInd, _ := NewSma(3)
|
||||
defer batchInd.Close()
|
||||
got := batchInd.Batch(input)
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("batch len = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if math.IsNaN(want[i]) && math.IsNaN(got[i]) {
|
||||
continue
|
||||
}
|
||||
if math.Abs(got[i]-want[i]) > 1e-9 {
|
||||
t.Fatalf("batch[%d] = %v, streaming = %v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiOutput(t *testing.T) {
|
||||
m, err := NewMacdIndicator(3, 6, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMacdIndicator: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
var ok bool
|
||||
var out MacdOutput
|
||||
for i := 0; i < 30; i++ {
|
||||
out, ok = m.Update(100 + float64(i))
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("macd never produced a value after warmup")
|
||||
}
|
||||
if math.IsNaN(out.Macd) {
|
||||
t.Fatal("macd value is NaN after warmup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBars(t *testing.T) {
|
||||
rb, err := NewRangeBars(2.0)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRangeBars: %v", err)
|
||||
}
|
||||
defer rb.Close()
|
||||
|
||||
total := 0
|
||||
for _, p := range []float64{100, 101, 103, 104, 99, 96, 102, 108, 95, 110} {
|
||||
bars := rb.Update(p, p, p, p, 1, 0)
|
||||
total += len(bars)
|
||||
}
|
||||
if total == 0 {
|
||||
t.Fatal("range bars produced no bars over a 15-point move")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfile(t *testing.T) {
|
||||
vp, err := NewVolumeProfile(10, 24)
|
||||
if err != nil {
|
||||
t.Fatalf("NewVolumeProfile: %v", err)
|
||||
}
|
||||
defer vp.Close()
|
||||
|
||||
var ok bool
|
||||
var snap VolumeProfileOutputScalars
|
||||
for i := 0; i < 50; i++ {
|
||||
price := 100 + 5*math.Sin(float64(i)*0.3)
|
||||
snap, ok = vp.Update(price, price+1, price-1, price, 1000, int64(i))
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("volume profile never produced a snapshot")
|
||||
}
|
||||
if len(snap.Values) == 0 {
|
||||
t.Fatal("volume profile returned an empty values buffer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayInput(t *testing.T) {
|
||||
ob, err := NewOrderBookImbalanceFull()
|
||||
if err != nil {
|
||||
t.Fatalf("NewOrderBookImbalanceFull: %v", err)
|
||||
}
|
||||
defer ob.Close()
|
||||
|
||||
bidPrice := []float64{99.9, 99.8, 99.7}
|
||||
bidSize := []float64{5, 3, 2}
|
||||
askPrice := []float64{100.1, 100.2, 100.3}
|
||||
askSize := []float64{1, 1, 1}
|
||||
v := ob.Update(bidPrice, bidSize, askPrice, askSize)
|
||||
if math.IsNaN(v) {
|
||||
t.Fatal("order-book imbalance is NaN on a populated book")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetReturnsToWarmup(t *testing.T) {
|
||||
s, _ := NewSma(3)
|
||||
defer s.Close()
|
||||
for _, v := range []float64{1, 2, 3} {
|
||||
s.Update(v)
|
||||
}
|
||||
s.Reset()
|
||||
if got := s.Update(10); !math.IsNaN(got) {
|
||||
t.Fatalf("after reset first update = %v, want NaN (warmup)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidParams(t *testing.T) {
|
||||
if _, err := NewSma(0); err == nil {
|
||||
t.Fatal("NewSma(0) should return ErrInvalidParams")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseIsIdempotent(t *testing.T) {
|
||||
s, _ := NewSma(3)
|
||||
s.Close()
|
||||
s.Close() // must not panic or double-free
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
prebuilt native binary, no system dependencies.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and any
|
||||
other C-capable language. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the Node.js binding (napi-rs);
|
||||
it exposes 200+ streaming-first indicators across sixteen families.
|
||||
@@ -55,8 +56,9 @@ the main repository and documentation site:
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/node/`](https://github.com/wickra-lib/wickra/tree/main/examples/node)
|
||||
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus a
|
||||
C ABI hub that any C-capable language (C, C++, Go, C#, Java, R) links against —
|
||||
all exposing the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
@@ -14,7 +14,18 @@ const wickra = require('..');
|
||||
// but intentionally not isReady/warmupPeriod, so they are excluded from the
|
||||
// Indicator completeness contract below (their interface is covered by the
|
||||
// dedicated bar-builder tests).
|
||||
const BAR_BUILDERS = new Set(['RenkoBars', 'KagiBars', 'PointAndFigureBars']);
|
||||
const BAR_BUILDERS = new Set([
|
||||
'RenkoBars',
|
||||
'KagiBars',
|
||||
'PointAndFigureBars',
|
||||
'RangeBars',
|
||||
'TickBars',
|
||||
'VolumeBars',
|
||||
'DollarBars',
|
||||
'ImbalanceBars',
|
||||
'RunBars',
|
||||
'ThreeLineBreakBars',
|
||||
]);
|
||||
|
||||
// An "indicator class" is an exported constructor whose prototype carries the
|
||||
// streaming `update` method. This excludes `version` (a plain function), the bar
|
||||
|
||||
@@ -28,6 +28,28 @@ function num(v) {
|
||||
// --- Scalar indicators: update(value) vs batch(prices) ---
|
||||
|
||||
const scalarFactories = {
|
||||
M2Measure: () => new wickra.M2Measure(20, 0.0, 0.02),
|
||||
UpsidePotentialRatio: () => new wickra.UpsidePotentialRatio(20, 0.0),
|
||||
GainToPainRatio: () => new wickra.GainToPainRatio(12),
|
||||
CommonSenseRatio: () => new wickra.CommonSenseRatio(20),
|
||||
KRatio: () => new wickra.KRatio(30),
|
||||
TailRatio: () => new wickra.TailRatio(20),
|
||||
MartinRatio: () => new wickra.MartinRatio(14),
|
||||
BurkeRatio: () => new wickra.BurkeRatio(12),
|
||||
SterlingRatio: () => new wickra.SterlingRatio(12),
|
||||
AUTOCORRPGRAM: () => new wickra.AUTOCORRPGRAM(10, 48),
|
||||
EVENBETTERSINE: () => new wickra.EVENBETTERSINE(40, 10),
|
||||
BANDPASS: () => new wickra.BANDPASS(20, 0.3),
|
||||
UNIVERSALOSC: () => new wickra.UNIVERSALOSC(20),
|
||||
ADAPTIVERSI: () => new wickra.ADAPTIVERSI(14),
|
||||
CTI: () => new wickra.CTI(20),
|
||||
TRENDFLEX: () => new wickra.TRENDFLEX(20),
|
||||
REFLEX: () => new wickra.REFLEX(20),
|
||||
HIGHPASS: () => new wickra.HIGHPASS(48),
|
||||
SAMPLEENT: () => new wickra.SAMPLEENT(20, 2, 0.2),
|
||||
SHANNONENT: () => new wickra.SHANNONENT(20, 8),
|
||||
ROLLINGMINMAX: () => new wickra.ROLLINGMINMAX(20),
|
||||
JARQUEBERA: () => new wickra.JARQUEBERA(20),
|
||||
BipowerVariation: () => new wickra.BipowerVariation(20),
|
||||
VolatilityOfVolatility: () => new wickra.VolatilityOfVolatility(20, 20),
|
||||
Garch11: () => new wickra.Garch11(0.000002, 0.1, 0.88),
|
||||
@@ -357,6 +379,31 @@ const candleScalar = {
|
||||
VolatilityRatio: { make: () => new wickra.VolatilityRatio(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ProjectionOscillator: { make: () => new wickra.ProjectionOscillator(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TimeBasedStop: { make: () => new wickra.TimeBasedStop(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
VolumeRsi: { make: () => new wickra.VolumeRsi(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
Wad: { make: () => new wickra.Wad(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TwiggsMoneyFlow: { make: () => new wickra.TwiggsMoneyFlow(21), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
TradeVolumeIndex: { make: () => new wickra.TradeVolumeIndex(0.25), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
IntradayIntensity: { make: () => new wickra.IntradayIntensity(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
BetterVolume: { make: () => new wickra.BetterVolume(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
ADAPTIVECCI: { make: () => new wickra.ADAPTIVECCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
PivotReversal: { make: () => new wickra.PivotReversal(1, 1), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
TDCamouflage: { make: () => new wickra.TDCamouflage(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDClop: { make: () => new wickra.TDClop(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDClopwin: { make: () => new wickra.TDClopwin(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDPropulsion: { make: () => new wickra.TDPropulsion(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDTrap: { make: () => new wickra.TDTrap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TDDWave: { make: () => new wickra.TDDWave(2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
HeikinAshiOscillator: { make: () => new wickra.HeikinAshiOscillator(5), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ThreeLineBreak: { make: () => new wickra.ThreeLineBreak(3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Tristar: { make: () => new wickra.Tristar(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
HaramiCross: { make: () => new wickra.HaramiCross(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TowerTopBottom: { make: () => new wickra.TowerTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
DumplingTop: { make: () => new wickra.DumplingTop(9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
NewPriceLines: { make: () => new wickra.NewPriceLines(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
FryPanBottom: { make: () => new wickra.FryPanBottom(9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
NakedPoc: { make: () => new wickra.NakedPoc(20, 24), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
SinglePrints: { make: () => new wickra.SinglePrints(20, 24), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ProfileShape: { make: () => new wickra.ProfileShape(20, 24), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
@@ -453,6 +500,17 @@ const multi = {
|
||||
AtrRatchet: { make: () => new wickra.AtrRatchet(14, 4.0, 0.1), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Nrtr: { make: () => new wickra.Nrtr(2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
ModifiedMaStop: { make: () => new wickra.ModifiedMaStop(14), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
VolumeWeightedMacd: { make: () => new wickra.VolumeWeightedMacd(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
|
||||
CentralPivotRange: { make: () => new wickra.CentralPivotRange(), fields: ['pivot', 'tc', 'bc'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
MurreyMathLines: { make: () => new wickra.MurreyMathLines(4), fields: ['mm8_8', 'mm7_8', 'mm6_8', 'mm5_8', 'mm4_8', 'mm3_8', 'mm2_8', 'mm1_8', 'mm0_8'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
AndrewsPitchfork: { make: () => new wickra.AndrewsPitchfork(2), fields: ['median', 'upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
VolumeWeightedSr: { make: () => new wickra.VolumeWeightedSr(3), fields: ['support', 'resistance'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
TDMovingAverage: { make: () => new wickra.TDMovingAverage(5, 13), fields: ['st1', 'st2'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
SmoothedHeikinAshi: { make: () => new wickra.SmoothedHeikinAshi(5), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Equivolume: { make: () => new wickra.Equivolume(20), fields: ['height', 'width'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
CandleVolume: { make: () => new wickra.CandleVolume(20), fields: ['body', 'width'], step: (ind, i) => ind.update(open[i], close[i], volume[i]), batch: (ind) => ind.batch(open, close, volume) },
|
||||
HighLowVolumeNodes: { make: () => new wickra.HighLowVolumeNodes(20, 24), fields: ['hvn', 'lvn'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
CompositeProfile: { make: () => new wickra.CompositeProfile(20, 24, 0.7), fields: ['poc', 'vah', 'val'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
@@ -622,6 +680,8 @@ const pairFactories = {
|
||||
VarianceRatio: () => new wickra.VarianceRatio(60, 2),
|
||||
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
|
||||
SpreadAr1Coefficient: () => new wickra.SpreadAr1Coefficient(40),
|
||||
KendallTau: () => new wickra.KendallTau(20),
|
||||
HasbrouckInformationShare: () => new wickra.HasbrouckInformationShare(2),
|
||||
};
|
||||
|
||||
for (const [name, make] of Object.entries(pairFactories)) {
|
||||
@@ -1225,7 +1285,7 @@ test('vpin / amihud / roll reference + streaming matches batch', () => {
|
||||
const price = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.25) * 4);
|
||||
const size = Array.from({ length: n }, (_, i) => 1 + (i % 5));
|
||||
const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0);
|
||||
for (const make of [() => new wickra.Vpin(8, 5), () => new wickra.AmihudIlliquidity(14), () => new wickra.RollMeasure(14)]) {
|
||||
for (const make of [() => new wickra.Vpin(8, 5), () => new wickra.AmihudIlliquidity(14), () => new wickra.RollMeasure(14), () => new wickra.TradeSignAutocorrelation(10), () => new wickra.Pin(10)]) {
|
||||
const batch = make().batch(price, size, isBuy);
|
||||
const streamer = make();
|
||||
assert.equal(batch.length, n);
|
||||
@@ -1234,6 +1294,16 @@ test('vpin / amihud / roll reference + streaming matches batch', () => {
|
||||
assert.ok((Number.isNaN(batch[i]) && s === null) || Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}`);
|
||||
}
|
||||
}
|
||||
// Trade-sign autocorrelation: alternating signs -> -1, all buys -> +1.
|
||||
let tsac = null;
|
||||
const tsacInd = new wickra.TradeSignAutocorrelation(10);
|
||||
for (let i = 0; i < 20; i++) tsac = tsacInd.update(100, 1, i % 2 === 0);
|
||||
assert.ok(Math.abs(tsac - -1.0) < 1e-12);
|
||||
// PIN: one-sided flow -> 1, balanced flow -> 0.
|
||||
let pin = null;
|
||||
const pinInd = new wickra.Pin(10);
|
||||
for (let i = 0; i < 20; i++) pin = pinInd.update(100, 1, true);
|
||||
assert.ok(Math.abs(pin - 1.0) < 1e-12);
|
||||
});
|
||||
|
||||
test('price-impact indicators reference values', () => {
|
||||
@@ -1387,6 +1457,56 @@ test('derivatives reject bad input', () => {
|
||||
assert.throws(() => new wickra.FundingBasis().update(100, 0));
|
||||
});
|
||||
|
||||
test('B16 derivatives reference values', () => {
|
||||
// Estimated leverage: oi / (long + short) = 200 / 100 = 2.
|
||||
assert.ok(Math.abs(new wickra.EstimatedLeverageRatio().update(200, 60, 40) - 2.0) < 1e-12);
|
||||
// OI-to-volume: oi / (buy + sell) = 100 / 50 = 2.
|
||||
assert.ok(Math.abs(new wickra.OiToVolumeRatio().update(100, 30, 20) - 2.0) < 1e-12);
|
||||
// Perpetual premium: (mark - index) / index = 0.5 / 100 = 0.005.
|
||||
assert.ok(Math.abs(new wickra.PerpetualPremiumIndex().update(100.5, 100.0) - 0.005) < 1e-12);
|
||||
// Funding-implied APR: rate * intervals = 0.0001 * 1095 = 0.1095.
|
||||
assert.ok(Math.abs(new wickra.FundingImpliedApr(1095).update(0.0001) - 0.1095) < 1e-12);
|
||||
// Open-interest momentum (period 2): warmup then ROC% = 100*(120 - 100)/100 = 20.
|
||||
const oim = new wickra.OpenInterestMomentum(2);
|
||||
assert.equal(oim.update(100), null);
|
||||
assert.equal(oim.update(110), null);
|
||||
assert.ok(Math.abs(oim.update(120) - 20.0) < 1e-12);
|
||||
});
|
||||
|
||||
test('B16 derivatives streaming matches batch', () => {
|
||||
const n = 30;
|
||||
const oi = Array.from({ length: n }, (_, i) => 1000 + 50 * Math.sin(i * 0.3));
|
||||
const longSz = Array.from({ length: n }, (_, i) => 600 + 20 * Math.cos(i * 0.2));
|
||||
const shortSz = Array.from({ length: n }, (_, i) => 400 + 15 * Math.sin(i * 0.4));
|
||||
const buy = Array.from({ length: n }, (_, i) => 300 + 10 * Math.sin(i * 0.5));
|
||||
const sell = Array.from({ length: n }, (_, i) => 250 + 12 * Math.cos(i * 0.35));
|
||||
const index = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.2));
|
||||
const mark = Array.from({ length: n }, (_, i) => index[i] + 0.05 * Math.cos(i * 0.3));
|
||||
const rate = Array.from({ length: n }, (_, i) => 0.0001 * Math.sin(i * 0.3));
|
||||
const cmp = (batch, s, i) =>
|
||||
assert.ok((s === null && Number.isNaN(batch[i])) || Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}`);
|
||||
|
||||
let b = new wickra.EstimatedLeverageRatio().batch(oi, longSz, shortSz);
|
||||
let st = new wickra.EstimatedLeverageRatio();
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(oi[i], longSz[i], shortSz[i]), i);
|
||||
|
||||
b = new wickra.OiToVolumeRatio().batch(oi, buy, sell);
|
||||
st = new wickra.OiToVolumeRatio();
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(oi[i], buy[i], sell[i]), i);
|
||||
|
||||
b = new wickra.PerpetualPremiumIndex().batch(mark, index);
|
||||
st = new wickra.PerpetualPremiumIndex();
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(mark[i], index[i]), i);
|
||||
|
||||
b = new wickra.FundingImpliedApr(1095).batch(rate);
|
||||
st = new wickra.FundingImpliedApr(1095);
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(rate[i]), i);
|
||||
|
||||
b = new wickra.OpenInterestMomentum(10).batch(oi);
|
||||
st = new wickra.OpenInterestMomentum(10);
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(oi[i]), i);
|
||||
});
|
||||
|
||||
test('market breadth: AdvanceDecline reference values', () => {
|
||||
// A breadth tick is the universe as parallel arrays; the sign of `change`
|
||||
// classifies each symbol as advancing / declining / unchanged.
|
||||
@@ -1649,3 +1769,72 @@ test('PointAndFigureBars closes a column on a 3-box reversal', () => {
|
||||
assert.equal(col[0].direction, 1);
|
||||
assert.ok(Math.abs(col[0].high - 15) < 1e-9 && Math.abs(col[0].low - 10) < 1e-9);
|
||||
});
|
||||
|
||||
test('RangeBars prints aligned bars on an up move', () => {
|
||||
const rb = new wickra.RangeBars(1.0);
|
||||
assert.deepEqual(rb.update(10), []); // seed
|
||||
const up = rb.update(13);
|
||||
assert.equal(up.length, 3);
|
||||
assert.ok(Math.abs(up[0].open - 10) < 1e-9 && Math.abs(up[2].close - 13) < 1e-9);
|
||||
assert.ok(up.every((b) => b.direction === 1));
|
||||
});
|
||||
|
||||
test('TickBars groups a fixed number of candles', () => {
|
||||
const tb = new wickra.TickBars(2);
|
||||
assert.deepEqual(tb.update(10, 11, 9, 10.5, 100), []);
|
||||
const out = tb.update(10.5, 12, 10, 11, 150);
|
||||
assert.equal(out.length, 1);
|
||||
assert.ok(Math.abs(out[0].open - 10) < 1e-9);
|
||||
assert.ok(Math.abs(out[0].high - 12) < 1e-9);
|
||||
assert.ok(Math.abs(out[0].low - 9) < 1e-9);
|
||||
assert.ok(Math.abs(out[0].close - 11) < 1e-9);
|
||||
assert.ok(Math.abs(out[0].volume - 250) < 1e-9);
|
||||
});
|
||||
|
||||
test('VolumeBars closes when accumulated volume crosses the threshold', () => {
|
||||
const vb = new wickra.VolumeBars(100);
|
||||
assert.deepEqual(vb.update(10, 10, 10, 10, 60), []);
|
||||
const out = vb.update(10.5, 10.5, 10.5, 10.5, 60);
|
||||
assert.equal(out.length, 1);
|
||||
assert.ok(Math.abs(out[0].volume - 120) < 1e-9);
|
||||
});
|
||||
|
||||
test('DollarBars closes when traded value crosses the threshold', () => {
|
||||
const db = new wickra.DollarBars(1000);
|
||||
assert.deepEqual(db.update(10, 10, 10, 10, 60), []);
|
||||
const out = db.update(10, 10, 10, 10, 60);
|
||||
assert.equal(out.length, 1);
|
||||
assert.ok(Math.abs(out[0].dollar - 1200) < 1e-9);
|
||||
assert.ok(Math.abs(out[0].volume - 120) < 1e-9);
|
||||
});
|
||||
|
||||
test('ImbalanceBars closes a buy bar at the threshold', () => {
|
||||
const ib = new wickra.ImbalanceBars(3.0);
|
||||
ib.update(10, 10, 10, 10);
|
||||
ib.update(11, 11, 11, 11);
|
||||
ib.update(12, 12, 12, 12);
|
||||
const out = ib.update(13, 13, 13, 13);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].direction, 1);
|
||||
assert.ok(Math.abs(out[0].imbalance - 3) < 1e-9);
|
||||
});
|
||||
|
||||
test('RunBars closes a buy run at the run length', () => {
|
||||
const rb = new wickra.RunBars(3);
|
||||
rb.update(10, 10, 10, 10);
|
||||
rb.update(11, 11, 11, 11);
|
||||
rb.update(12, 12, 12, 12);
|
||||
const out = rb.update(13, 13, 13, 13);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].direction, 1);
|
||||
assert.equal(out[0].length, 3);
|
||||
});
|
||||
|
||||
test('ThreeLineBreakBars draws a rising line', () => {
|
||||
const tlb = new wickra.ThreeLineBreakBars(3);
|
||||
assert.deepEqual(tlb.update(10), []); // seed
|
||||
const out = tlb.update(11);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].direction, 1);
|
||||
assert.ok(Math.abs(out[0].open - 10) < 1e-9 && Math.abs(out[0].close - 11) < 1e-9);
|
||||
});
|
||||
|
||||
Vendored
+776
@@ -239,6 +239,31 @@ export interface ProjectionBandsValue {
|
||||
middle: number
|
||||
lower: number
|
||||
}
|
||||
export interface CentralPivotRangeValue {
|
||||
pivot: number
|
||||
tc: number
|
||||
bc: number
|
||||
}
|
||||
export interface MurreyMathLinesValue {
|
||||
mm8_8: number
|
||||
mm7_8: number
|
||||
mm6_8: number
|
||||
mm5_8: number
|
||||
mm4_8: number
|
||||
mm3_8: number
|
||||
mm2_8: number
|
||||
mm1_8: number
|
||||
mm0_8: number
|
||||
}
|
||||
export interface AndrewsPitchforkValue {
|
||||
median: number
|
||||
upper: number
|
||||
lower: number
|
||||
}
|
||||
export interface VolumeWeightedSrValue {
|
||||
support: number
|
||||
resistance: number
|
||||
}
|
||||
export interface DoubleBollingerValue {
|
||||
upperOuter: number
|
||||
upperInner: number
|
||||
@@ -317,6 +342,10 @@ export interface TdSequentialValue {
|
||||
countdown: number
|
||||
direction: number
|
||||
}
|
||||
export interface TdMovingAverageValue {
|
||||
st1: number
|
||||
st2: number
|
||||
}
|
||||
/** TD Lines output pair: latest TDST resistance / support (NaN if unset). */
|
||||
export interface TdLinesValue {
|
||||
resistance: number
|
||||
@@ -356,6 +385,20 @@ export interface HeikinAshiValue {
|
||||
low: number
|
||||
close: number
|
||||
}
|
||||
export interface SmoothedHeikinAshiValue {
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
}
|
||||
export interface EquivolumeValue {
|
||||
height: number
|
||||
width: number
|
||||
}
|
||||
export interface CandleVolumeValue {
|
||||
body: number
|
||||
width: number
|
||||
}
|
||||
export interface ValueAreaValue {
|
||||
poc: number
|
||||
vah: number
|
||||
@@ -366,6 +409,15 @@ export interface VolumeProfileValue {
|
||||
priceHigh: number
|
||||
bins: Array<number>
|
||||
}
|
||||
export interface HighLowVolumeNodesValue {
|
||||
hvn: number
|
||||
lvn: number
|
||||
}
|
||||
export interface CompositeProfileValue {
|
||||
poc: number
|
||||
vah: number
|
||||
val: number
|
||||
}
|
||||
export interface TpoProfileValue {
|
||||
priceLow: number
|
||||
priceHigh: number
|
||||
@@ -416,6 +468,54 @@ export interface PnfColumnValue {
|
||||
high: number
|
||||
low: number
|
||||
}
|
||||
export interface RangeBarValue {
|
||||
open: number
|
||||
close: number
|
||||
direction: number
|
||||
}
|
||||
export interface TickBarValue {
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
volume: number
|
||||
}
|
||||
export interface VolumeBarValue {
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
volume: number
|
||||
}
|
||||
export interface DollarBarValue {
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
volume: number
|
||||
dollar: number
|
||||
}
|
||||
export interface ImbalanceBarValue {
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
imbalance: number
|
||||
direction: number
|
||||
}
|
||||
export interface RunBarValue {
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
length: number
|
||||
direction: number
|
||||
}
|
||||
export interface LineBreakBarValue {
|
||||
open: number
|
||||
close: number
|
||||
direction: number
|
||||
}
|
||||
export interface SessionHighLowValue {
|
||||
high: number
|
||||
low: number
|
||||
@@ -489,6 +589,11 @@ export interface FibTimeZonesValue {
|
||||
onZone: number
|
||||
barsToNext: number
|
||||
}
|
||||
export interface VolumeWeightedMacdValue {
|
||||
macd: number
|
||||
signal: number
|
||||
histogram: number
|
||||
}
|
||||
export type SmaNode = SMA
|
||||
export declare class SMA {
|
||||
constructor(period: number)
|
||||
@@ -1047,6 +1152,204 @@ export declare class BipowerVariation {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type JarqueBeraNode = JARQUEBERA
|
||||
export declare class JARQUEBERA {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type RollingMinMaxScalerNode = ROLLINGMINMAX
|
||||
export declare class ROLLINGMINMAX {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type HighpassFilterNode = HIGHPASS
|
||||
export declare class HIGHPASS {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ReflexNode = REFLEX
|
||||
export declare class REFLEX {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TrendflexNode = TRENDFLEX
|
||||
export declare class TRENDFLEX {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CorrelationTrendIndicatorNode = CTI
|
||||
export declare class CTI {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AdaptiveRsiNode = ADAPTIVERSI
|
||||
export declare class ADAPTIVERSI {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type UniversalOscillatorNode = UNIVERSALOSC
|
||||
export declare class UNIVERSALOSC {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SterlingRatioNode = SterlingRatio
|
||||
export declare class SterlingRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BurkeRatioNode = BurkeRatio
|
||||
export declare class BurkeRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type MartinRatioNode = MartinRatio
|
||||
export declare class MartinRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TailRatioNode = TailRatio
|
||||
export declare class TailRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type KRatioNode = KRatio
|
||||
export declare class KRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CommonSenseRatioNode = CommonSenseRatio
|
||||
export declare class CommonSenseRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type GainToPainRatioNode = GainToPainRatio
|
||||
export declare class GainToPainRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type UpsidePotentialRatioNode = UpsidePotentialRatio
|
||||
export declare class UpsidePotentialRatio {
|
||||
constructor(period: number, mar: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type M2MeasureNode = M2Measure
|
||||
export declare class M2Measure {
|
||||
constructor(period: number, riskFree: number, benchmarkStddev: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BandpassFilterNode = BANDPASS
|
||||
export declare class BANDPASS {
|
||||
constructor(period: number, bandwidth: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type EvenBetterSinewaveNode = EVENBETTERSINE
|
||||
export declare class EVENBETTERSINE {
|
||||
constructor(hpPeriod: number, ssfLength: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AutocorrelationPeriodogramNode = AUTOCORRPGRAM
|
||||
export declare class AUTOCORRPGRAM {
|
||||
constructor(minPeriod: number, maxPeriod: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ShannonEntropyNode = SHANNONENT
|
||||
export declare class SHANNONENT {
|
||||
constructor(period: number, bins: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SampleEntropyNode = SAMPLEENT
|
||||
export declare class SAMPLEENT {
|
||||
constructor(period: number, m: number, rFactor: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type EwmaVolatilityNode = EwmaVolatility
|
||||
export declare class EwmaVolatility {
|
||||
constructor(lambda: number)
|
||||
@@ -1258,6 +1561,19 @@ export declare class DistanceSsd {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type KendallTauNode = KendallTau
|
||||
export declare class KendallTau {
|
||||
constructor(period: number)
|
||||
update(x: number, y: number): number | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a length-`n` array
|
||||
* with `NaN` for warmup positions.
|
||||
*/
|
||||
batch(x: Array<number>, y: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BetaNeutralSpreadNode = BetaNeutralSpread
|
||||
export declare class BetaNeutralSpread {
|
||||
constructor(period: number)
|
||||
@@ -1271,6 +1587,19 @@ export declare class BetaNeutralSpread {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type HasbrouckInformationShareNode = HasbrouckInformationShare
|
||||
export declare class HasbrouckInformationShare {
|
||||
constructor(period: number)
|
||||
update(x: number, y: number): number | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a length-`n` array
|
||||
* with `NaN` for warmup positions.
|
||||
*/
|
||||
batch(x: Array<number>, y: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type PairSpreadZScoreNode = PairSpreadZScore
|
||||
/**
|
||||
* Pair spread z-score: two ctor params (`betaPeriod`, `zPeriod`), one `(a, b)`
|
||||
@@ -1697,6 +2026,15 @@ export declare class TimeBasedStop {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AdaptiveCciNode = ADAPTIVECCI
|
||||
export declare class ADAPTIVECCI {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type StochNode = Stochastic
|
||||
export declare class Stochastic {
|
||||
constructor(kPeriod: number, dPeriod: number)
|
||||
@@ -2786,6 +3124,51 @@ export declare class ProjectionBands {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CentralPivotRangeNode = CentralPivotRange
|
||||
export declare class CentralPivotRange {
|
||||
constructor()
|
||||
update(high: number, low: number, close: number): CentralPivotRangeValue | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type MurreyMathLinesNode = MurreyMathLines
|
||||
export declare class MurreyMathLines {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number): MurreyMathLinesValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AndrewsPitchforkNode = AndrewsPitchfork
|
||||
export declare class AndrewsPitchfork {
|
||||
constructor(strength: number)
|
||||
update(high: number, low: number): AndrewsPitchforkValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VolumeWeightedSrNode = VolumeWeightedSr
|
||||
export declare class VolumeWeightedSr {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, volume: number): VolumeWeightedSrValue | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type PivotReversalNode = PivotReversal
|
||||
export declare class PivotReversal {
|
||||
constructor(left: number, right: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DoubleBollingerNode = DoubleBollinger
|
||||
export declare class DoubleBollinger {
|
||||
constructor(period: number, kInner: number, kOuter: number)
|
||||
@@ -2945,6 +3328,24 @@ export declare class TDCombo {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdDWaveNode = TDDWave
|
||||
export declare class TDDWave {
|
||||
constructor(strength: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdMovingAverageNode = TDMovingAverage
|
||||
export declare class TDMovingAverage {
|
||||
constructor(periodSt1: number, periodSt2: number)
|
||||
update(high: number, low: number): TdMovingAverageValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdCountdownNode = TDCountdown
|
||||
export declare class TDCountdown {
|
||||
constructor(setupLookback: number, setupTarget: number, countdownLookback: number, countdownTarget: number)
|
||||
@@ -3126,6 +3527,78 @@ export declare class HeikinAshi {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type HeikinAshiOscillatorNode = HeikinAshiOscillator
|
||||
export declare class HeikinAshiOscillator {
|
||||
constructor(period: number)
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ThreeLineBreakNode = ThreeLineBreak
|
||||
export declare class ThreeLineBreak {
|
||||
constructor(lines: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SmoothedHeikinAshiNode = SmoothedHeikinAshi
|
||||
export declare class SmoothedHeikinAshi {
|
||||
constructor(period: number)
|
||||
update(open: number, high: number, low: number, close: number): SmoothedHeikinAshiValue | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type EquivolumeNode = Equivolume
|
||||
export declare class Equivolume {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, volume: number): EquivolumeValue | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CandleVolumeNode = CandleVolume
|
||||
export declare class CandleVolume {
|
||||
constructor(period: number)
|
||||
update(open: number, close: number, volume: number): CandleVolumeValue | null
|
||||
batch(open: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FryPanBottomNode = FryPanBottom
|
||||
export declare class FryPanBottom {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DumplingTopNode = DumplingTop
|
||||
export declare class DumplingTop {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type NewPriceLinesNode = NewPriceLines
|
||||
export declare class NewPriceLines {
|
||||
constructor(count: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ValueAreaNode = ValueArea
|
||||
export declare class ValueArea {
|
||||
constructor(period: number, binCount: number, valueAreaPct: number)
|
||||
@@ -3135,6 +3608,51 @@ export declare class ValueArea {
|
||||
update(high: number, low: number, volume: number): ValueAreaValue | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
}
|
||||
export type NakedPocNode = NakedPoc
|
||||
export declare class NakedPoc {
|
||||
constructor(sessionLen: number, binCount: number)
|
||||
update(high: number, low: number, close: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SinglePrintsNode = SinglePrints
|
||||
export declare class SinglePrints {
|
||||
constructor(period: number, binCount: number)
|
||||
update(high: number, low: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ProfileShapeNode = ProfileShape
|
||||
export declare class ProfileShape {
|
||||
constructor(period: number, binCount: number)
|
||||
update(high: number, low: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type HighLowVolumeNodesNode = HighLowVolumeNodes
|
||||
export declare class HighLowVolumeNodes {
|
||||
constructor(period: number, binCount: number)
|
||||
update(high: number, low: number, volume: number): HighLowVolumeNodesValue | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CompositeProfileNode = CompositeProfile
|
||||
export declare class CompositeProfile {
|
||||
constructor(period: number, binCount: number, valueAreaPct: number)
|
||||
update(high: number, low: number, volume: number): CompositeProfileValue | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VolumeProfileNode = VolumeProfile
|
||||
export declare class VolumeProfile {
|
||||
constructor(period: number, binCount: number)
|
||||
@@ -3858,6 +4376,78 @@ export declare class ThreeDrives {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdCamouflageNode = TDCamouflage
|
||||
export declare class TDCamouflage {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdClopNode = TDClop
|
||||
export declare class TDClop {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdClopwinNode = TDClopwin
|
||||
export declare class TDClopwin {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdPropulsionNode = TDPropulsion
|
||||
export declare class TDPropulsion {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TdTrapNode = TDTrap
|
||||
export declare class TDTrap {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TristarNode = Tristar
|
||||
export declare class Tristar {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type HaramiCrossNode = HaramiCross
|
||||
export declare class HaramiCross {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TowerTopBottomNode = TowerTopBottom
|
||||
export declare class TowerTopBottom {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OrderBookImbalanceTop1Node = OrderBookImbalanceTop1
|
||||
export declare class OrderBookImbalanceTop1 {
|
||||
constructor()
|
||||
@@ -3939,6 +4529,24 @@ export declare class TradeImbalance {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TradeSignAutocorrelationNode = TradeSignAutocorrelation
|
||||
export declare class TradeSignAutocorrelation {
|
||||
constructor(period: number)
|
||||
update(price: number, size: number, isBuy: boolean): number | null
|
||||
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type PinNode = Pin
|
||||
export declare class Pin {
|
||||
constructor(window: number)
|
||||
update(price: number, size: number, isBuy: boolean): number | null
|
||||
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OrderFlowImbalanceNode = OrderFlowImbalance
|
||||
export declare class OrderFlowImbalance {
|
||||
constructor(period: number)
|
||||
@@ -4119,6 +4727,51 @@ export declare class CalendarSpread {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type EstimatedLeverageRatioNode = EstimatedLeverageRatio
|
||||
export declare class EstimatedLeverageRatio {
|
||||
constructor()
|
||||
update(openInterest: number, longSize: number, shortSize: number): number | null
|
||||
batch(openInterest: Array<number>, longSize: Array<number>, shortSize: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OiToVolumeRatioNode = OiToVolumeRatio
|
||||
export declare class OiToVolumeRatio {
|
||||
constructor()
|
||||
update(openInterest: number, takerBuyVolume: number, takerSellVolume: number): number | null
|
||||
batch(openInterest: Array<number>, takerBuyVolume: Array<number>, takerSellVolume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type PerpetualPremiumIndexNode = PerpetualPremiumIndex
|
||||
export declare class PerpetualPremiumIndex {
|
||||
constructor()
|
||||
update(markPrice: number, indexPrice: number): number | null
|
||||
batch(markPrice: Array<number>, indexPrice: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FundingImpliedAprNode = FundingImpliedApr
|
||||
export declare class FundingImpliedApr {
|
||||
constructor(intervalsPerYear: number)
|
||||
update(fundingRate: number): number | null
|
||||
batch(fundingRate: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OpenInterestMomentumNode = OpenInterestMomentum
|
||||
export declare class OpenInterestMomentum {
|
||||
constructor(period: number)
|
||||
update(openInterest: number): number | null
|
||||
batch(openInterest: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AdvanceDeclineNode = AdvanceDecline
|
||||
export declare class AdvanceDecline {
|
||||
constructor()
|
||||
@@ -4423,6 +5076,62 @@ export declare class PointAndFigureBars {
|
||||
reversal(): number
|
||||
reset(): void
|
||||
}
|
||||
export type RangeBarsNode = RangeBars
|
||||
export declare class RangeBars {
|
||||
constructor(range: number)
|
||||
update(close: number): Array<RangeBarValue>
|
||||
batch(close: Array<number>): Array<RangeBarValue>
|
||||
range(): number
|
||||
reset(): void
|
||||
}
|
||||
export type TickBarsNode = TickBars
|
||||
export declare class TickBars {
|
||||
constructor(ticks: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number): Array<TickBarValue>
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<TickBarValue>
|
||||
ticks(): number
|
||||
reset(): void
|
||||
}
|
||||
export type VolumeBarsNode = VolumeBars
|
||||
export declare class VolumeBars {
|
||||
constructor(volumePerBar: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number): Array<VolumeBarValue>
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<VolumeBarValue>
|
||||
volumePerBar(): number
|
||||
reset(): void
|
||||
}
|
||||
export type DollarBarsNode = DollarBars
|
||||
export declare class DollarBars {
|
||||
constructor(dollarPerBar: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number): Array<DollarBarValue>
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<DollarBarValue>
|
||||
dollarPerBar(): number
|
||||
reset(): void
|
||||
}
|
||||
export type ImbalanceBarsNode = ImbalanceBars
|
||||
export declare class ImbalanceBars {
|
||||
constructor(threshold: number)
|
||||
update(open: number, high: number, low: number, close: number): Array<ImbalanceBarValue>
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<ImbalanceBarValue>
|
||||
threshold(): number
|
||||
reset(): void
|
||||
}
|
||||
export type RunBarsNode = RunBars
|
||||
export declare class RunBars {
|
||||
constructor(runLength: number)
|
||||
update(open: number, high: number, low: number, close: number): Array<RunBarValue>
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<RunBarValue>
|
||||
runLength(): number
|
||||
reset(): void
|
||||
}
|
||||
export type ThreeLineBreakBarsNode = ThreeLineBreakBars
|
||||
export declare class ThreeLineBreakBars {
|
||||
constructor(lines: number)
|
||||
update(close: number): Array<LineBreakBarValue>
|
||||
batch(close: Array<number>): Array<LineBreakBarValue>
|
||||
lines(): number
|
||||
reset(): void
|
||||
}
|
||||
export type AlphaNode = Alpha
|
||||
export declare class Alpha {
|
||||
constructor(period: number, riskFree: number)
|
||||
@@ -4640,3 +5349,70 @@ export declare class FibTimeZones {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VolumeRsiNode = VolumeRsi
|
||||
export declare class VolumeRsi {
|
||||
constructor(period: number)
|
||||
update(close: number, volume: number): number | null
|
||||
batch(close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type WadNode = Wad
|
||||
export declare class Wad {
|
||||
constructor()
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TwiggsMoneyFlowNode = TwiggsMoneyFlow
|
||||
export declare class TwiggsMoneyFlow {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TradeVolumeIndexNode = TradeVolumeIndex
|
||||
export declare class TradeVolumeIndex {
|
||||
constructor(minTick: number)
|
||||
update(close: number, volume: number): number | null
|
||||
batch(close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type IntradayIntensityNode = IntradayIntensity
|
||||
export declare class IntradayIntensity {
|
||||
constructor()
|
||||
update(high: number, low: number, close: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BetterVolumeNode = BetterVolume
|
||||
export declare class BetterVolume {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VolumeWeightedMacdNode = VolumeWeightedMacd
|
||||
export declare class VolumeWeightedMacd {
|
||||
constructor(fast: number, slow: number, signal: number)
|
||||
update(close: number, volume: number): VolumeWeightedMacdValue | null
|
||||
/**
|
||||
* Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`.
|
||||
* Warmup positions are `NaN`.
|
||||
*/
|
||||
batch(close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
|
||||
+75
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.7",
|
||||
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-arm64.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.7",
|
||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-x64.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.7",
|
||||
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-arm64-gnu.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.7",
|
||||
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.linux-x64-gnu.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-arm64-msvc",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.7",
|
||||
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-arm64-msvc.node",
|
||||
"files": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.7",
|
||||
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.win32-x64-msvc.node",
|
||||
"files": [
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.7",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -15,12 +15,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.6.2",
|
||||
"wickra-darwin-x64": "0.6.2",
|
||||
"wickra-linux-arm64-gnu": "0.6.2",
|
||||
"wickra-linux-x64-gnu": "0.6.2",
|
||||
"wickra-win32-arm64-msvc": "0.6.2",
|
||||
"wickra-win32-x64-msvc": "0.6.2"
|
||||
"wickra-darwin-arm64": "0.7.7",
|
||||
"wickra-darwin-x64": "0.7.7",
|
||||
"wickra-linux-arm64-gnu": "0.7.7",
|
||||
"wickra-linux-x64-gnu": "0.7.7",
|
||||
"wickra-win32-arm64-msvc": "0.7.7",
|
||||
"wickra-win32-x64-msvc": "0.7.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.2.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.7.7.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.2.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.7.7.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.2.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.7.7.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.2.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.7.7.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.2.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.7.7.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.2.tgz",
|
||||
"version": "0.7.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.7.7.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.6.2",
|
||||
"version": "0.7.7",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <support@wickra.org>",
|
||||
"main": "index.js",
|
||||
@@ -47,12 +47,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-linux-x64-gnu": "0.6.2",
|
||||
"wickra-linux-arm64-gnu": "0.6.2",
|
||||
"wickra-darwin-x64": "0.6.2",
|
||||
"wickra-darwin-arm64": "0.6.2",
|
||||
"wickra-win32-x64-msvc": "0.6.2",
|
||||
"wickra-win32-arm64-msvc": "0.6.2"
|
||||
"wickra-linux-x64-gnu": "0.7.7",
|
||||
"wickra-linux-arm64-gnu": "0.7.7",
|
||||
"wickra-darwin-x64": "0.7.7",
|
||||
"wickra-darwin-arm64": "0.7.7",
|
||||
"wickra-win32-x64-msvc": "0.7.7",
|
||||
"wickra-win32-arm64-msvc": "0.7.7"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,8 @@
|
||||
system dependencies, no C build tooling.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and any
|
||||
other C-capable language. Every indicator is an O(1)
|
||||
streaming state machine, so live trading bots and historical backtests share
|
||||
the exact same implementation. This package is the Python binding (PyO3); it
|
||||
exposes 200+ streaming-first indicators across sixteen families.
|
||||
@@ -54,8 +55,9 @@ the main repository and documentation site:
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable examples:** [`examples/python/`](https://github.com/wickra-lib/wickra/tree/main/examples/python)
|
||||
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus a
|
||||
C ABI hub that any C-capable language (C, C++, Go, C#, Java, R) links against —
|
||||
all exposing the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
@@ -72,13 +72,23 @@ class Sample:
|
||||
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."""
|
||||
def time_call(fn: Callable[[], None], iterations: int, rounds: int = 5) -> float:
|
||||
"""Time ``fn`` over ``iterations`` calls per round, across ``rounds`` rounds.
|
||||
|
||||
Returns the *median* round's wall seconds for one round of ``iterations``
|
||||
calls. Taking the median across several rounds damps the OS scheduling and
|
||||
GC jitter that a single timing pass would otherwise bake into the result,
|
||||
so the per-iteration figure is stable run-to-run. Callers keep dividing the
|
||||
return value by ``iterations``.
|
||||
"""
|
||||
fn() # one warmup call to populate caches
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
fn()
|
||||
return time.perf_counter() - start
|
||||
rounds_s: List[float] = []
|
||||
for _ in range(rounds):
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
fn()
|
||||
rounds_s.append(time.perf_counter() - start)
|
||||
return statistics.median(rounds_s)
|
||||
|
||||
|
||||
def gen_prices(n: int, seed: int = 0xC0FFEE) -> np.ndarray:
|
||||
@@ -457,6 +467,161 @@ def talipp_bollinger_streaming(seed: np.ndarray, live: np.ndarray) -> Optional[C
|
||||
return run
|
||||
|
||||
|
||||
# Recompute streaming peers: batch-only libraries have no incremental API, so
|
||||
# the only honest way to drive them tick-by-tick is to re-run the full batch
|
||||
# over the grown history on every new price. These runners expose exactly that
|
||||
# cost — the gap Wickra's O(1) update closes.
|
||||
|
||||
|
||||
def _talib_recompute_streaming(seed, live, fn):
|
||||
def run() -> None:
|
||||
history = list(seed)
|
||||
for p in live:
|
||||
history.append(float(p))
|
||||
fn(np.asarray(history))
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _pandas_ta_recompute_streaming(seed, live, fn):
|
||||
def run() -> None:
|
||||
history = list(seed)
|
||||
for p in live:
|
||||
history.append(float(p))
|
||||
fn(PD.Series(history))
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _tulipy_recompute_streaming(seed, live, fn):
|
||||
def run() -> None:
|
||||
history = list(seed)
|
||||
for p in live:
|
||||
history.append(float(p))
|
||||
fn(np.asarray(history, dtype=np.float64))
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def _finta_recompute_streaming(seed, live, fn):
|
||||
def run() -> None:
|
||||
history = list(seed)
|
||||
for p in live:
|
||||
history.append(float(p))
|
||||
arr = np.asarray(history)
|
||||
fn(PD.DataFrame({"open": arr, "high": arr, "low": arr, "close": arr, "volume": np.ones_like(arr)}))
|
||||
|
||||
return run
|
||||
|
||||
|
||||
def talib_sma_streaming(seed, live):
|
||||
if TALIB is None:
|
||||
return None
|
||||
return _talib_recompute_streaming(seed, live, lambda a: TALIB.SMA(a, timeperiod=20))
|
||||
|
||||
|
||||
def pandas_ta_sma_streaming(seed, live):
|
||||
if PANDAS_TA is None or PD is None:
|
||||
return None
|
||||
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.sma(s, length=20))
|
||||
|
||||
|
||||
def tulipy_sma_streaming(seed, live):
|
||||
if TULIPY is None:
|
||||
return None
|
||||
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.sma(a, 20))
|
||||
|
||||
|
||||
def finta_sma_streaming(seed, live):
|
||||
if FINTA is None or PD is None:
|
||||
return None
|
||||
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.SMA(df, period=20))
|
||||
|
||||
|
||||
def talib_ema_streaming(seed, live):
|
||||
if TALIB is None:
|
||||
return None
|
||||
return _talib_recompute_streaming(seed, live, lambda a: TALIB.EMA(a, timeperiod=20))
|
||||
|
||||
|
||||
def pandas_ta_ema_streaming(seed, live):
|
||||
if PANDAS_TA is None or PD is None:
|
||||
return None
|
||||
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.ema(s, length=20))
|
||||
|
||||
|
||||
def tulipy_ema_streaming(seed, live):
|
||||
if TULIPY is None:
|
||||
return None
|
||||
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.ema(a, 20))
|
||||
|
||||
|
||||
def finta_ema_streaming(seed, live):
|
||||
if FINTA is None or PD is None:
|
||||
return None
|
||||
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.EMA(df, period=20))
|
||||
|
||||
|
||||
def tulipy_rsi_streaming(seed, live):
|
||||
if TULIPY is None:
|
||||
return None
|
||||
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.rsi(a, 14))
|
||||
|
||||
|
||||
def finta_rsi_streaming(seed, live):
|
||||
if FINTA is None or PD is None:
|
||||
return None
|
||||
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.RSI(df, period=14))
|
||||
|
||||
|
||||
def talib_macd_streaming(seed, live):
|
||||
if TALIB is None:
|
||||
return None
|
||||
return _talib_recompute_streaming(seed, live, lambda a: TALIB.MACD(a))
|
||||
|
||||
|
||||
def pandas_ta_macd_streaming(seed, live):
|
||||
if PANDAS_TA is None or PD is None:
|
||||
return None
|
||||
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.macd(s))
|
||||
|
||||
|
||||
def tulipy_macd_streaming(seed, live):
|
||||
if TULIPY is None:
|
||||
return None
|
||||
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.macd(a, 12, 26, 9))
|
||||
|
||||
|
||||
def finta_macd_streaming(seed, live):
|
||||
if FINTA is None or PD is None:
|
||||
return None
|
||||
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.MACD(df))
|
||||
|
||||
|
||||
def talib_bollinger_streaming(seed, live):
|
||||
if TALIB is None:
|
||||
return None
|
||||
return _talib_recompute_streaming(seed, live, lambda a: TALIB.BBANDS(a, timeperiod=20, nbdevup=2, nbdevdn=2))
|
||||
|
||||
|
||||
def pandas_ta_bollinger_streaming(seed, live):
|
||||
if PANDAS_TA is None or PD is None:
|
||||
return None
|
||||
return _pandas_ta_recompute_streaming(seed, live, lambda s: PANDAS_TA.bbands(s, length=20, std=2.0))
|
||||
|
||||
|
||||
def tulipy_bollinger_streaming(seed, live):
|
||||
if TULIPY is None:
|
||||
return None
|
||||
return _tulipy_recompute_streaming(seed, live, lambda a: TULIPY.bbands(a, 20, 2.0))
|
||||
|
||||
|
||||
def finta_bollinger_streaming(seed, live):
|
||||
if FINTA is None or PD is None:
|
||||
return None
|
||||
return _finta_recompute_streaming(seed, live, lambda df: FINTA.TA.BBANDS(df, period=20, std_multiplier=2.0))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Runner
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -519,36 +684,54 @@ STREAMING_INDICATORS = [
|
||||
("SMA(20)", [
|
||||
("Wickra", wickra_sma_streaming),
|
||||
("talipp", talipp_sma_streaming),
|
||||
("TA-Lib", talib_sma_streaming),
|
||||
("pandas-ta", pandas_ta_sma_streaming),
|
||||
("tulipy", tulipy_sma_streaming),
|
||||
("finta", finta_sma_streaming),
|
||||
]),
|
||||
("EMA(20)", [
|
||||
("Wickra", wickra_ema_streaming),
|
||||
("talipp", talipp_ema_streaming),
|
||||
("TA-Lib", talib_ema_streaming),
|
||||
("pandas-ta", pandas_ta_ema_streaming),
|
||||
("tulipy", tulipy_ema_streaming),
|
||||
("finta", finta_ema_streaming),
|
||||
]),
|
||||
("RSI(14)", [
|
||||
("Wickra", wickra_rsi_streaming),
|
||||
("talipp", talipp_rsi_streaming),
|
||||
("TA-Lib", talib_rsi_streaming),
|
||||
("pandas-ta", pandas_ta_rsi_streaming),
|
||||
("talipp", talipp_rsi_streaming),
|
||||
("tulipy", tulipy_rsi_streaming),
|
||||
("finta", finta_rsi_streaming),
|
||||
]),
|
||||
("MACD(12, 26, 9)", [
|
||||
("Wickra", wickra_macd_streaming),
|
||||
("talipp", talipp_macd_streaming),
|
||||
("TA-Lib", talib_macd_streaming),
|
||||
("pandas-ta", pandas_ta_macd_streaming),
|
||||
("tulipy", tulipy_macd_streaming),
|
||||
("finta", finta_macd_streaming),
|
||||
]),
|
||||
("Bollinger(20, 2.0)", [
|
||||
("Wickra", wickra_bollinger_streaming),
|
||||
("talipp", talipp_bollinger_streaming),
|
||||
("TA-Lib", talib_bollinger_streaming),
|
||||
("pandas-ta", pandas_ta_bollinger_streaming),
|
||||
("tulipy", tulipy_bollinger_streaming),
|
||||
("finta", finta_bollinger_streaming),
|
||||
]),
|
||||
]
|
||||
|
||||
|
||||
def run_batch(prices: np.ndarray, iterations: int) -> List[Sample]:
|
||||
def run_batch(prices: np.ndarray, iterations: int, rounds: 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)
|
||||
secs = time_call(runner, iterations, rounds)
|
||||
out.append(Sample(lib_name, indicator_name, "batch", secs, iterations))
|
||||
return out
|
||||
|
||||
@@ -558,6 +741,7 @@ def run_ohlc(
|
||||
low: np.ndarray,
|
||||
close: np.ndarray,
|
||||
iterations: int,
|
||||
rounds: int,
|
||||
) -> List[Sample]:
|
||||
out: List[Sample] = []
|
||||
for indicator_name, libs in OHLC_INDICATORS:
|
||||
@@ -565,12 +749,12 @@ def run_ohlc(
|
||||
runner = factory(high, low, close)
|
||||
if runner is None:
|
||||
continue
|
||||
secs = time_call(runner, iterations)
|
||||
secs = time_call(runner, iterations, rounds)
|
||||
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]:
|
||||
def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int, rounds: int) -> List[Sample]:
|
||||
out: List[Sample] = []
|
||||
seed = prices[:streaming_window]
|
||||
live = prices[streaming_window:]
|
||||
@@ -581,7 +765,7 @@ def run_streaming(prices: np.ndarray, streaming_window: int, iterations: int) ->
|
||||
runner = factory(seed, live)
|
||||
if runner is None:
|
||||
continue
|
||||
secs = time_call(runner, iterations)
|
||||
secs = time_call(runner, iterations, rounds)
|
||||
sample = Sample(lib_name, indicator_name, "streaming", secs, iterations)
|
||||
sample.iterations = iterations * len(live) # per-tick normalization
|
||||
out.append(sample)
|
||||
@@ -629,6 +813,12 @@ 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(
|
||||
"--rounds",
|
||||
type=int,
|
||||
default=5,
|
||||
help="batch timing rounds; the median round is reported to damp jitter",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--streaming-window",
|
||||
type=int,
|
||||
@@ -641,6 +831,14 @@ def parse_args() -> argparse.Namespace:
|
||||
default=3,
|
||||
help="repetitions of the streaming workload (each iteration replays all live ticks)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--streaming-rounds",
|
||||
type=int,
|
||||
default=2,
|
||||
help="streaming timing rounds; the median round is reported",
|
||||
)
|
||||
parser.add_argument("--skip-batch", action="store_true", help="skip the batch tables")
|
||||
parser.add_argument("--skip-streaming", action="store_true", help="skip the streaming tables")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -660,11 +858,14 @@ def main() -> None:
|
||||
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)
|
||||
rows: List[Sample] = []
|
||||
if not args.skip_batch:
|
||||
rows += run_batch(prices, args.iterations, args.rounds)
|
||||
rows += run_ohlc(high, low, close, args.iterations, args.rounds)
|
||||
if not args.skip_streaming:
|
||||
rows += run_streaming(prices, args.streaming_window, args.streaming_iterations, args.streaming_rounds)
|
||||
|
||||
print(render_table(batch_rows + ohlc_rows + streaming_rows))
|
||||
print(render_table(rows))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.6.2"
|
||||
version = "0.7.7"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -25,6 +25,29 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
M2Measure,
|
||||
UpsidePotentialRatio,
|
||||
GainToPainRatio,
|
||||
CommonSenseRatio,
|
||||
KRatio,
|
||||
TailRatio,
|
||||
MartinRatio,
|
||||
BurkeRatio,
|
||||
SterlingRatio,
|
||||
AUTOCORRPGRAM,
|
||||
EVENBETTERSINE,
|
||||
BANDPASS,
|
||||
ADAPTIVECCI,
|
||||
UNIVERSALOSC,
|
||||
ADAPTIVERSI,
|
||||
CTI,
|
||||
TRENDFLEX,
|
||||
REFLEX,
|
||||
HIGHPASS,
|
||||
SAMPLEENT,
|
||||
SHANNONENT,
|
||||
ROLLINGMINMAX,
|
||||
JARQUEBERA,
|
||||
TimeBasedStop,
|
||||
ProjectionOscillator,
|
||||
VolatilityCone,
|
||||
@@ -194,6 +217,13 @@ from ._wickra import (
|
||||
RogersSatchellVolatility,
|
||||
YangZhangVolatility,
|
||||
# Volume
|
||||
VolumeWeightedMacd,
|
||||
BetterVolume,
|
||||
IntradayIntensity,
|
||||
TradeVolumeIndex,
|
||||
TwiggsMoneyFlow,
|
||||
Wad,
|
||||
VolumeRsi,
|
||||
OBV,
|
||||
VWAP,
|
||||
RollingVWAP,
|
||||
@@ -214,6 +244,7 @@ from ._wickra import (
|
||||
MarketFacilitationIndex,
|
||||
EaseOfMovement,
|
||||
# Statistics
|
||||
KendallTau,
|
||||
SpreadBollingerBands,
|
||||
KalmanHedgeRatio,
|
||||
GrangerCausality,
|
||||
@@ -287,6 +318,11 @@ from ._wickra import (
|
||||
FractalChaosBands,
|
||||
VwapStdDevBands,
|
||||
# Pivots & S/R
|
||||
PivotReversal,
|
||||
VolumeWeightedSr,
|
||||
AndrewsPitchfork,
|
||||
MurreyMathLines,
|
||||
CentralPivotRange,
|
||||
ClassicPivots,
|
||||
FibonacciPivots,
|
||||
Camarilla,
|
||||
@@ -295,6 +331,13 @@ from ._wickra import (
|
||||
WilliamsFractals,
|
||||
ZigZag,
|
||||
# DeMark
|
||||
TDMovingAverage,
|
||||
TDDWave,
|
||||
TDTrap,
|
||||
TDPropulsion,
|
||||
TDClopwin,
|
||||
TDClop,
|
||||
TDCamouflage,
|
||||
TDSetup,
|
||||
TDSequential,
|
||||
TDDeMarker,
|
||||
@@ -310,17 +353,40 @@ from ._wickra import (
|
||||
# Ichimoku & alternative charts
|
||||
Ichimoku,
|
||||
HeikinAshi,
|
||||
SmoothedHeikinAshi,
|
||||
HeikinAshiOscillator,
|
||||
ThreeLineBreak,
|
||||
Equivolume,
|
||||
CandleVolume,
|
||||
# Market Profile
|
||||
CompositeProfile,
|
||||
HighLowVolumeNodes,
|
||||
ProfileShape,
|
||||
SinglePrints,
|
||||
NakedPoc,
|
||||
ValueArea,
|
||||
VolumeProfile,
|
||||
TpoProfile,
|
||||
InitialBalance,
|
||||
OpeningRange,
|
||||
# Alt-Chart Bars
|
||||
ThreeLineBreakBars,
|
||||
RunBars,
|
||||
ImbalanceBars,
|
||||
DollarBars,
|
||||
VolumeBars,
|
||||
TickBars,
|
||||
RangeBars,
|
||||
RenkoBars,
|
||||
KagiBars,
|
||||
PointAndFigureBars,
|
||||
# Candlestick patterns
|
||||
TowerTopBottom,
|
||||
HaramiCross,
|
||||
Tristar,
|
||||
FryPanBottom,
|
||||
DumplingTop,
|
||||
NewPriceLines,
|
||||
Doji,
|
||||
Hammer,
|
||||
InvertedHammer,
|
||||
@@ -419,6 +485,8 @@ from ._wickra import (
|
||||
QuotedSpread,
|
||||
DepthSlope,
|
||||
# Microstructure: trade flow
|
||||
Pin,
|
||||
TradeSignAutocorrelation,
|
||||
RollMeasure,
|
||||
AmihudIlliquidity,
|
||||
Vpin,
|
||||
@@ -426,12 +494,18 @@ from ._wickra import (
|
||||
CumulativeVolumeDelta,
|
||||
TradeImbalance,
|
||||
# Microstructure: price impact
|
||||
HasbrouckInformationShare,
|
||||
EffectiveSpread,
|
||||
RealizedSpread,
|
||||
KylesLambda,
|
||||
# Microstructure: footprint
|
||||
Footprint,
|
||||
# Derivatives
|
||||
OpenInterestMomentum,
|
||||
FundingImpliedApr,
|
||||
PerpetualPremiumIndex,
|
||||
OiToVolumeRatio,
|
||||
EstimatedLeverageRatio,
|
||||
FundingRate,
|
||||
FundingRateMean,
|
||||
FundingRateZScore,
|
||||
@@ -494,6 +568,29 @@ from ._wickra import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"M2Measure",
|
||||
"UpsidePotentialRatio",
|
||||
"GainToPainRatio",
|
||||
"CommonSenseRatio",
|
||||
"KRatio",
|
||||
"TailRatio",
|
||||
"MartinRatio",
|
||||
"BurkeRatio",
|
||||
"SterlingRatio",
|
||||
"AUTOCORRPGRAM",
|
||||
"EVENBETTERSINE",
|
||||
"BANDPASS",
|
||||
"ADAPTIVECCI",
|
||||
"UNIVERSALOSC",
|
||||
"ADAPTIVERSI",
|
||||
"CTI",
|
||||
"TRENDFLEX",
|
||||
"REFLEX",
|
||||
"HIGHPASS",
|
||||
"SAMPLEENT",
|
||||
"SHANNONENT",
|
||||
"ROLLINGMINMAX",
|
||||
"JARQUEBERA",
|
||||
"TimeBasedStop",
|
||||
"ProjectionOscillator",
|
||||
"VolatilityCone",
|
||||
@@ -664,6 +761,13 @@ __all__ = [
|
||||
"RogersSatchellVolatility",
|
||||
"YangZhangVolatility",
|
||||
# Volume
|
||||
"VolumeWeightedMacd",
|
||||
"BetterVolume",
|
||||
"IntradayIntensity",
|
||||
"TradeVolumeIndex",
|
||||
"TwiggsMoneyFlow",
|
||||
"Wad",
|
||||
"VolumeRsi",
|
||||
"OBV",
|
||||
"VWAP",
|
||||
"RollingVWAP",
|
||||
@@ -684,6 +788,7 @@ __all__ = [
|
||||
"MarketFacilitationIndex",
|
||||
"EaseOfMovement",
|
||||
# Statistics
|
||||
"KendallTau",
|
||||
"SpreadBollingerBands",
|
||||
"KalmanHedgeRatio",
|
||||
"GrangerCausality",
|
||||
@@ -757,6 +862,11 @@ __all__ = [
|
||||
"FractalChaosBands",
|
||||
"VwapStdDevBands",
|
||||
# Pivots & S/R
|
||||
"PivotReversal",
|
||||
"VolumeWeightedSr",
|
||||
"AndrewsPitchfork",
|
||||
"MurreyMathLines",
|
||||
"CentralPivotRange",
|
||||
"ClassicPivots",
|
||||
"FibonacciPivots",
|
||||
"Camarilla",
|
||||
@@ -765,6 +875,13 @@ __all__ = [
|
||||
"WilliamsFractals",
|
||||
"ZigZag",
|
||||
# DeMark
|
||||
"TDMovingAverage",
|
||||
"TDDWave",
|
||||
"TDTrap",
|
||||
"TDPropulsion",
|
||||
"TDClopwin",
|
||||
"TDClop",
|
||||
"TDCamouflage",
|
||||
"TDSetup",
|
||||
"TDSequential",
|
||||
"TDDeMarker",
|
||||
@@ -780,17 +897,40 @@ __all__ = [
|
||||
# Ichimoku & alternative charts
|
||||
"Ichimoku",
|
||||
"HeikinAshi",
|
||||
"SmoothedHeikinAshi",
|
||||
"HeikinAshiOscillator",
|
||||
"ThreeLineBreak",
|
||||
"Equivolume",
|
||||
"CandleVolume",
|
||||
# Market Profile
|
||||
"CompositeProfile",
|
||||
"HighLowVolumeNodes",
|
||||
"ProfileShape",
|
||||
"SinglePrints",
|
||||
"NakedPoc",
|
||||
"ValueArea",
|
||||
"VolumeProfile",
|
||||
"TpoProfile",
|
||||
"InitialBalance",
|
||||
"OpeningRange",
|
||||
# Alt-Chart Bars
|
||||
"ThreeLineBreakBars",
|
||||
"RunBars",
|
||||
"ImbalanceBars",
|
||||
"DollarBars",
|
||||
"VolumeBars",
|
||||
"TickBars",
|
||||
"RangeBars",
|
||||
"RenkoBars",
|
||||
"KagiBars",
|
||||
"PointAndFigureBars",
|
||||
# Candlestick patterns
|
||||
"TowerTopBottom",
|
||||
"HaramiCross",
|
||||
"Tristar",
|
||||
"FryPanBottom",
|
||||
"DumplingTop",
|
||||
"NewPriceLines",
|
||||
"Doji",
|
||||
"Hammer",
|
||||
"InvertedHammer",
|
||||
@@ -889,6 +1029,8 @@ __all__ = [
|
||||
"QuotedSpread",
|
||||
"DepthSlope",
|
||||
# Microstructure: trade flow
|
||||
"Pin",
|
||||
"TradeSignAutocorrelation",
|
||||
"RollMeasure",
|
||||
"AmihudIlliquidity",
|
||||
"Vpin",
|
||||
@@ -896,12 +1038,18 @@ __all__ = [
|
||||
"CumulativeVolumeDelta",
|
||||
"TradeImbalance",
|
||||
# Microstructure: price impact
|
||||
"HasbrouckInformationShare",
|
||||
"EffectiveSpread",
|
||||
"RealizedSpread",
|
||||
"KylesLambda",
|
||||
# Microstructure: footprint
|
||||
"Footprint",
|
||||
# Derivatives
|
||||
"OpenInterestMomentum",
|
||||
"FundingImpliedApr",
|
||||
"PerpetualPremiumIndex",
|
||||
"OiToVolumeRatio",
|
||||
"EstimatedLeverageRatio",
|
||||
"FundingRate",
|
||||
"FundingRateMean",
|
||||
"FundingRateZScore",
|
||||
|
||||
+4352
-150
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,28 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
# --- Scalar (f64 -> f64) indicators ---------------------------------------
|
||||
|
||||
SCALAR = [
|
||||
(ta.M2Measure, (20, 0.0, 0.02)),
|
||||
(ta.UpsidePotentialRatio, (20, 0.0)),
|
||||
(ta.GainToPainRatio, (12,)),
|
||||
(ta.CommonSenseRatio, (20,)),
|
||||
(ta.KRatio, (30,)),
|
||||
(ta.TailRatio, (20,)),
|
||||
(ta.MartinRatio, (14,)),
|
||||
(ta.BurkeRatio, (12,)),
|
||||
(ta.SterlingRatio, (12,)),
|
||||
(ta.AUTOCORRPGRAM, (10, 48)),
|
||||
(ta.EVENBETTERSINE, (40, 10)),
|
||||
(ta.BANDPASS, (20, 0.3)),
|
||||
(ta.UNIVERSALOSC, (20,)),
|
||||
(ta.ADAPTIVERSI, (14,)),
|
||||
(ta.CTI, (20,)),
|
||||
(ta.TRENDFLEX, (20,)),
|
||||
(ta.REFLEX, (20,)),
|
||||
(ta.HIGHPASS, (48,)),
|
||||
(ta.SAMPLEENT, (20, 2, 0.2)),
|
||||
(ta.SHANNONENT, (20, 8)),
|
||||
(ta.ROLLINGMINMAX, (20,)),
|
||||
(ta.JARQUEBERA, (20,)),
|
||||
(ta.BipowerVariation, (20,)),
|
||||
(ta.VolatilityOfVolatility, (20, 20)),
|
||||
(ta.Garch11, (0.000002, 0.1, 0.88)),
|
||||
@@ -204,6 +226,8 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
|
||||
# --- Two-series (asset, benchmark) indicators -----------------------------
|
||||
|
||||
PAIR = [
|
||||
(ta.HasbrouckInformationShare, (2,)),
|
||||
(ta.KendallTau, (20,)),
|
||||
(ta.SpreadAr1Coefficient, (40,)),
|
||||
(ta.GrangerCausality, (60, 1)),
|
||||
(ta.VarianceRatio, (60, 2)),
|
||||
@@ -368,6 +392,103 @@ def test_relative_strength_streaming_matches_batch():
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"ProfileShape": (
|
||||
lambda: ta.ProfileShape(20, 24),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
),
|
||||
"SinglePrints": (
|
||||
lambda: ta.SinglePrints(20, 24),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
),
|
||||
"NakedPoc": (
|
||||
lambda: ta.NakedPoc(20, 24),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"FryPanBottom": (
|
||||
lambda: ta.FryPanBottom(9),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"NewPriceLines": (
|
||||
lambda: ta.NewPriceLines(5),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"DumplingTop": (
|
||||
lambda: ta.DumplingTop(9),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"TowerTopBottom": (
|
||||
lambda: ta.TowerTopBottom(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"HaramiCross": (
|
||||
lambda: ta.HaramiCross(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Tristar": (
|
||||
lambda: ta.Tristar(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"ThreeLineBreak": (
|
||||
lambda: ta.ThreeLineBreak(3),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"HeikinAshiOscillator": (
|
||||
lambda: ta.HeikinAshiOscillator(5),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"TDDWave": (
|
||||
lambda: ta.TDDWave(2),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"TDTrap": (
|
||||
lambda: ta.TDTrap(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"TDPropulsion": (
|
||||
lambda: ta.TDPropulsion(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"TDClopwin": (
|
||||
lambda: ta.TDClopwin(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"TDClop": (
|
||||
lambda: ta.TDClop(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"TDCamouflage": (
|
||||
lambda: ta.TDCamouflage(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"PivotReversal": (
|
||||
lambda: ta.PivotReversal(1, 1),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"ADAPTIVECCI": (lambda: ta.ADAPTIVECCI(20), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"BetterVolume": (
|
||||
lambda: ta.BetterVolume(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"IntradayIntensity": (
|
||||
lambda: ta.IntradayIntensity(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"TradeVolumeIndex": (
|
||||
lambda: ta.TradeVolumeIndex(0.25),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"TwiggsMoneyFlow": (
|
||||
lambda: ta.TwiggsMoneyFlow(21),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"Wad": (
|
||||
lambda: ta.Wad(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
),
|
||||
"VolumeRsi": (
|
||||
lambda: ta.VolumeRsi(14),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
),
|
||||
"TimeBasedStop": (lambda: ta.TimeBasedStop(5), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"ProjectionOscillator": (lambda: ta.ProjectionOscillator(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"VolatilityRatio": (lambda: ta.VolatilityRatio(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
@@ -909,6 +1030,61 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"CompositeProfile": (
|
||||
lambda: ta.CompositeProfile(20, 24, 0.7),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
3,
|
||||
),
|
||||
"HighLowVolumeNodes": (
|
||||
lambda: ta.HighLowVolumeNodes(20, 24),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
2,
|
||||
),
|
||||
"CandleVolume": (
|
||||
lambda: ta.CandleVolume(20),
|
||||
lambda ind, h, l, c, v: ind.batch(c, c, v),
|
||||
2,
|
||||
),
|
||||
"Equivolume": (
|
||||
lambda: ta.Equivolume(20),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
2,
|
||||
),
|
||||
"SmoothedHeikinAshi": (
|
||||
lambda: ta.SmoothedHeikinAshi(5),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
4,
|
||||
),
|
||||
"TDMovingAverage": (
|
||||
lambda: ta.TDMovingAverage(5, 13),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
2,
|
||||
),
|
||||
"VolumeWeightedSr": (
|
||||
lambda: ta.VolumeWeightedSr(3),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
2,
|
||||
),
|
||||
"AndrewsPitchfork": (
|
||||
lambda: ta.AndrewsPitchfork(2),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
3,
|
||||
),
|
||||
"MurreyMathLines": (
|
||||
lambda: ta.MurreyMathLines(4),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
9,
|
||||
),
|
||||
"CentralPivotRange": (
|
||||
lambda: ta.CentralPivotRange(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
3,
|
||||
),
|
||||
"VolumeWeightedMacd": (
|
||||
lambda: ta.VolumeWeightedMacd(12, 26, 9),
|
||||
lambda ind, h, l, c, v: ind.batch(c, v),
|
||||
3,
|
||||
),
|
||||
"ModifiedMaStop": (
|
||||
lambda: ta.ModifiedMaStop(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
@@ -1580,7 +1756,7 @@ def test_kvo_constant_series_is_zero():
|
||||
assert v == pytest.approx(0.0, abs=1e-12)
|
||||
|
||||
|
||||
def test_williams_ad_reference():
|
||||
def test_wad_reference():
|
||||
# bar 0 seeds prev_close = 10.
|
||||
# bar 1: prev=10, today high=13, low=8, close=12 (up day).
|
||||
# TR_l = min(10, 8) = 8 -> delta = 12 - 8 = 4. AD = 4.
|
||||
@@ -3040,6 +3216,172 @@ def test_modified_ma_stop_reference():
|
||||
assert t.update(c) is None
|
||||
assert t.update(candles[13]) == pytest.approx((107.0, 1.0))
|
||||
|
||||
|
||||
def test_volume_rsi_reference():
|
||||
t = ta.VolumeRsi(14)
|
||||
|
||||
|
||||
def test_twiggs_money_flow_reference():
|
||||
t = ta.TwiggsMoneyFlow(21)
|
||||
|
||||
|
||||
def test_trade_volume_index_reference():
|
||||
t = ta.TradeVolumeIndex(0.25)
|
||||
|
||||
|
||||
def test_intraday_intensity_reference():
|
||||
t = ta.IntradayIntensity()
|
||||
|
||||
|
||||
def test_better_volume_reference():
|
||||
t = ta.BetterVolume(14)
|
||||
|
||||
|
||||
def test_volume_weighted_macd_reference():
|
||||
t = ta.VolumeWeightedMacd(12, 26, 9)
|
||||
|
||||
|
||||
def test_kendall_tau_reference():
|
||||
t = ta.KendallTau(20)
|
||||
|
||||
|
||||
def test_central_pivot_range_reference():
|
||||
t = ta.CentralPivotRange()
|
||||
assert t.update((105.0, 110.0, 90.0, 105.0, 1.0, 0)) == pytest.approx((101.66666666666667, 103.33333333333334, 100.0))
|
||||
|
||||
|
||||
def test_murrey_math_lines_reference():
|
||||
t = ta.MurreyMathLines(4)
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 0)) is None
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 1)) is None
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 2)) is None
|
||||
assert t.update((140.0, 180.0, 100.0, 140.0, 1.0, 3)) == pytest.approx((180.0, 170.0, 160.0, 150.0, 140.0, 130.0, 120.0, 110.0, 100.0))
|
||||
|
||||
|
||||
def test_andrews_pitchfork_reference():
|
||||
t = ta.AndrewsPitchfork(2)
|
||||
# Warmup: no pitchfork until three alternating swing pivots are confirmed.
|
||||
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) is None
|
||||
|
||||
|
||||
def test_volume_weighted_sr_reference():
|
||||
t = ta.VolumeWeightedSr(3)
|
||||
assert t.update((100.0, 102.0, 98.0, 100.0, 1.0, 0)) is None
|
||||
assert t.update((100.0, 104.0, 96.0, 100.0, 1.0, 1)) is None
|
||||
assert t.update((100.0, 106.0, 94.0, 100.0, 1.0, 2)) == pytest.approx((96.0, 104.0))
|
||||
|
||||
|
||||
def test_pivot_reversal_reference():
|
||||
t = ta.PivotReversal(1, 1)
|
||||
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 0)) is None
|
||||
assert t.update((11.5, 12.0, 11.0, 11.5, 1.0, 1)) is None
|
||||
# Pivot high = 12 confirmed; close 9.5 has not crossed it.
|
||||
assert t.update((9.5, 10.0, 9.0, 9.5, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((9.0, 11.0, 9.0, 9.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
# Close 13 > pivot high 12 with prev close 9 below it -> bullish reversal.
|
||||
assert t.update((13.0, 14.0, 12.5, 13.0, 1.0, 4)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
|
||||
def test_td_camouflage_reference():
|
||||
t = ta.TDCamouflage()
|
||||
assert t.update((10.0, 11.0, 8.0, 10.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((9.0, 10.0, 7.0, 9.5, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_td_clop_reference():
|
||||
t = ta.TDClop()
|
||||
assert t.update((10.0, 12.0, 9.0, 11.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((9.0, 13.0, 8.0, 12.0, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_td_clopwin_reference():
|
||||
t = ta.TDClopwin()
|
||||
assert t.update((10.0, 15.0, 9.0, 14.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((11.0, 14.0, 10.0, 13.0, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_td_propulsion_reference():
|
||||
t = ta.TDPropulsion()
|
||||
assert t.update((9.5, 11.0, 9.0, 10.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((10.5, 12.0, 10.0, 11.5, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_td_trap_reference():
|
||||
t = ta.TDTrap()
|
||||
assert t.update((100.0, 110.0, 90.0, 100.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((101.5, 108.0, 95.0, 102.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((106.0, 112.0, 100.0, 109.0, 1.0, 2)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
|
||||
def test_heikin_ashi_oscillator_reference():
|
||||
t = ta.HeikinAshiOscillator(5)
|
||||
|
||||
|
||||
def test_three_line_break_reference():
|
||||
t = ta.ThreeLineBreak(3)
|
||||
|
||||
|
||||
def test_smoothed_heikin_ashi_reference():
|
||||
t = ta.SmoothedHeikinAshi(5)
|
||||
|
||||
|
||||
def test_equivolume_reference():
|
||||
t = ta.Equivolume(20)
|
||||
|
||||
|
||||
def test_candle_volume_reference():
|
||||
t = ta.CandleVolume(20)
|
||||
|
||||
|
||||
def test_tristar_reference():
|
||||
t = ta.Tristar()
|
||||
assert t.update((100.0, 101.0, 99.0, 100.02, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((105.0, 106.0, 104.0, 105.02, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 101.0, 99.0, 100.02, 1.0, 2)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_harami_cross_reference():
|
||||
t = ta.HaramiCross()
|
||||
assert t.update((110.0, 110.2, 99.8, 100.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((105.0, 106.0, 104.0, 105.02, 1.0, 1)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_tower_top_bottom_reference():
|
||||
t = ta.TowerTopBottom()
|
||||
assert t.update((100.0, 110.1, 99.9, 110.0, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((105.0, 107.0, 103.0, 105.1, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((110.0, 110.1, 99.9, 100.0, 1.0, 2)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
|
||||
def test_hasbrouck_information_share_reference():
|
||||
t = ta.HasbrouckInformationShare(2)
|
||||
assert t.update(7.0, 9.0) is None
|
||||
assert t.update(7.0, 9.0) is None
|
||||
assert t.update(7.0, 9.0) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_naked_poc_reference():
|
||||
t = ta.NakedPoc(20, 24)
|
||||
|
||||
|
||||
def test_single_prints_reference():
|
||||
t = ta.SinglePrints(20, 24)
|
||||
|
||||
|
||||
def test_profile_shape_reference():
|
||||
t = ta.ProfileShape(20, 24)
|
||||
|
||||
|
||||
def test_high_low_volume_nodes_reference():
|
||||
t = ta.HighLowVolumeNodes(20, 24)
|
||||
|
||||
|
||||
def test_composite_profile_reference():
|
||||
t = ta.CompositeProfile(20, 24, 0.7)
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -3380,6 +3722,8 @@ def test_tradeflow_indicators_streaming_equals_batch():
|
||||
lambda: ta.Vpin(8.0, 5),
|
||||
lambda: ta.AmihudIlliquidity(14),
|
||||
lambda: ta.RollMeasure(14),
|
||||
lambda: ta.TradeSignAutocorrelation(10),
|
||||
lambda: ta.Pin(10),
|
||||
):
|
||||
batch = make().batch(price, size, is_buy)
|
||||
streamer = make()
|
||||
@@ -3391,6 +3735,34 @@ def test_tradeflow_indicators_streaming_equals_batch():
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_trade_sign_autocorrelation_reference():
|
||||
# Perfectly alternating aggressor signs -> lag-1 autocorrelation -1.
|
||||
t = ta.TradeSignAutocorrelation(10)
|
||||
last = None
|
||||
for i in range(20):
|
||||
last = t.update(100.0, 1.0, i % 2 == 0)
|
||||
assert last == pytest.approx(-1.0)
|
||||
# All buys -> perfectly persistent flow -> +1.
|
||||
t2 = ta.TradeSignAutocorrelation(10)
|
||||
for _ in range(20):
|
||||
last2 = t2.update(100.0, 1.0, True)
|
||||
assert last2 == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_pin_reference():
|
||||
# One-sided flow (all buys) -> maximally informed -> PIN 1.
|
||||
p = ta.Pin(10)
|
||||
last = None
|
||||
for _ in range(20):
|
||||
last = p.update(100.0, 1.0, True)
|
||||
assert last == pytest.approx(1.0)
|
||||
# Balanced flow -> uninformed -> PIN 0.
|
||||
p2 = ta.Pin(10)
|
||||
for i in range(20):
|
||||
last2 = p2.update(100.0, 1.0, i % 2 == 0)
|
||||
assert last2 == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_price_impact_indicators_streaming_equals_batch():
|
||||
n = 40
|
||||
mid = np.array([100.0 + 0.5 * math.sin(i * 0.4) for i in range(n)], dtype=np.float64)
|
||||
@@ -3761,6 +4133,71 @@ def test_basis_indicators_streaming_equals_batch():
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_b16_derivatives_reference():
|
||||
# Estimated leverage: oi / (long + short) = 200 / 100 = 2.
|
||||
assert ta.EstimatedLeverageRatio().update(200.0, 60.0, 40.0) == pytest.approx(2.0)
|
||||
# OI-to-volume: oi / (buy + sell) = 100 / 50 = 2.
|
||||
assert ta.OiToVolumeRatio().update(100.0, 30.0, 20.0) == pytest.approx(2.0)
|
||||
# Perpetual premium: (mark - index) / index = 0.5 / 100 = 0.005.
|
||||
assert ta.PerpetualPremiumIndex().update(100.5, 100.0) == pytest.approx(0.005)
|
||||
# Funding-implied APR: rate * intervals = 0.0001 * 1095 = 0.1095.
|
||||
assert ta.FundingImpliedApr(1095.0).update(0.0001) == pytest.approx(0.1095)
|
||||
# Open-interest momentum (period 2): warmup then ROC% = 100*(120-100)/100 = 20.
|
||||
oim = ta.OpenInterestMomentum(2)
|
||||
assert oim.update(100.0) is None
|
||||
assert oim.update(110.0) is None
|
||||
assert oim.update(120.0) == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_b16_derivatives_streaming_equals_batch():
|
||||
n = 40
|
||||
oi = np.array([1000.0 + 50.0 * math.sin(i * 0.3) for i in range(n)], dtype=np.float64)
|
||||
long_sz = np.array([600.0 + 20.0 * math.cos(i * 0.2) for i in range(n)], dtype=np.float64)
|
||||
short_sz = np.array([400.0 + 15.0 * math.sin(i * 0.4) for i in range(n)], dtype=np.float64)
|
||||
buy = np.array([300.0 + 10.0 * math.sin(i * 0.5) for i in range(n)], dtype=np.float64)
|
||||
sell = np.array([250.0 + 12.0 * math.cos(i * 0.35) for i in range(n)], dtype=np.float64)
|
||||
index = np.array([100.0 + math.sin(i * 0.2) for i in range(n)], dtype=np.float64)
|
||||
mark = np.array([index[i] + 0.05 * math.cos(i * 0.3) for i in range(n)], dtype=np.float64)
|
||||
rate = np.array([0.0001 * math.sin(i * 0.3) for i in range(n)], dtype=np.float64)
|
||||
|
||||
# EstimatedLeverageRatio; update(open_interest, long_size, short_size).
|
||||
batch = ta.EstimatedLeverageRatio().batch(oi, long_sz, short_sz)
|
||||
streamer = ta.EstimatedLeverageRatio()
|
||||
streamed = np.array(
|
||||
[streamer.update(oi[i], long_sz[i], short_sz[i]) for i in range(n)], dtype=np.float64
|
||||
)
|
||||
assert batch.shape == (n,)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
# OiToVolumeRatio; update(open_interest, taker_buy_volume, taker_sell_volume).
|
||||
batch = ta.OiToVolumeRatio().batch(oi, buy, sell)
|
||||
streamer = ta.OiToVolumeRatio()
|
||||
streamed = np.array(
|
||||
[streamer.update(oi[i], buy[i], sell[i]) for i in range(n)], dtype=np.float64
|
||||
)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
# PerpetualPremiumIndex; update(mark_price, index_price).
|
||||
batch = ta.PerpetualPremiumIndex().batch(mark, index)
|
||||
streamer = ta.PerpetualPremiumIndex()
|
||||
streamed = np.array(
|
||||
[streamer.update(mark[i], index[i]) for i in range(n)], dtype=np.float64
|
||||
)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
# FundingImpliedApr; update(funding_rate).
|
||||
batch = ta.FundingImpliedApr(1095.0).batch(rate)
|
||||
streamer = ta.FundingImpliedApr(1095.0)
|
||||
streamed = np.array([streamer.update(rate[i]) for i in range(n)], dtype=np.float64)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
# OpenInterestMomentum; update(open_interest).
|
||||
batch = ta.OpenInterestMomentum(10).batch(oi)
|
||||
streamer = ta.OpenInterestMomentum(10)
|
||||
streamed = np.array([streamer.update(oi[i]) for i in range(n)], dtype=np.float64)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
|
||||
# --- Alt-Chart Bars ------------------------------------------------------
|
||||
|
||||
|
||||
@@ -3800,3 +4237,83 @@ def test_bar_builders_reset():
|
||||
r.update(15.0)
|
||||
r.reset()
|
||||
assert r.update(50.0) == [] # re-seeds after reset
|
||||
|
||||
|
||||
def test_range_bars_reference():
|
||||
rb = ta.RangeBars(1.0)
|
||||
assert rb.update(10.0) == [] # seed
|
||||
assert rb.update(13.0) == [(10.0, 11.0, 1), (11.0, 12.0, 1), (12.0, 13.0, 1)]
|
||||
|
||||
|
||||
def test_range_bars_batch_shape():
|
||||
rb = ta.RangeBars(1.0)
|
||||
out = rb.batch(np.array([10.0, 11.0, 12.0, 13.0]))
|
||||
assert out.shape == (3, 3)
|
||||
np.testing.assert_allclose(out[:, 2], [1.0, 1.0, 1.0])
|
||||
|
||||
|
||||
def test_tick_bars_reference():
|
||||
tb = ta.TickBars(2)
|
||||
assert tb.update(10.0, 11.0, 9.0, 10.5, 100.0) == []
|
||||
out = tb.update(10.5, 12.0, 10.0, 11.0, 150.0)
|
||||
assert len(out) == 1
|
||||
assert out[0] == (10.0, 12.0, 9.0, 11.0, 250.0)
|
||||
|
||||
|
||||
def test_tick_bars_batch_shape():
|
||||
tb = ta.TickBars(2)
|
||||
col = np.array([10.0, 10.0, 10.0, 10.0])
|
||||
vol = np.array([1.0, 1.0, 1.0, 1.0])
|
||||
out = tb.batch(col, col, col, col, vol)
|
||||
assert out.shape == (2, 5)
|
||||
|
||||
|
||||
def test_volume_bars_reference():
|
||||
vb = ta.VolumeBars(100.0)
|
||||
assert vb.update(10.0, 10.0, 10.0, 10.0, 60.0) == []
|
||||
out = vb.update(10.5, 10.5, 10.5, 10.5, 60.0)
|
||||
assert len(out) == 1
|
||||
assert out[0][4] == 120.0 # accumulated volume
|
||||
|
||||
|
||||
def test_dollar_bars_reference():
|
||||
db = ta.DollarBars(1000.0)
|
||||
assert db.update(10.0, 10.0, 10.0, 10.0, 60.0) == [] # 600
|
||||
out = db.update(10.0, 10.0, 10.0, 10.0, 60.0) # 1200 >= 1000
|
||||
assert len(out) == 1
|
||||
assert out[0][4] == 120.0 # volume
|
||||
assert out[0][5] == 1200.0 # traded value
|
||||
|
||||
|
||||
def test_imbalance_bars_reference():
|
||||
ib = ta.ImbalanceBars(3.0)
|
||||
assert ib.update(10.0, 10.0, 10.0, 10.0) == [] # seed
|
||||
ib.update(11.0, 11.0, 11.0, 11.0) # +1
|
||||
ib.update(12.0, 12.0, 12.0, 12.0) # +2
|
||||
out = ib.update(13.0, 13.0, 13.0, 13.0) # +3 -> close
|
||||
assert len(out) == 1
|
||||
assert out[0][4] == 3.0 # imbalance
|
||||
assert out[0][5] == 1 # direction
|
||||
|
||||
|
||||
def test_run_bars_reference():
|
||||
rb = ta.RunBars(3)
|
||||
assert rb.update(10.0, 10.0, 10.0, 10.0) == [] # seed
|
||||
rb.update(11.0, 11.0, 11.0, 11.0) # run 1
|
||||
rb.update(12.0, 12.0, 12.0, 12.0) # run 2
|
||||
out = rb.update(13.0, 13.0, 13.0, 13.0) # run 3 -> close
|
||||
assert len(out) == 1
|
||||
assert out[0][4] == 3 # length
|
||||
assert out[0][5] == 1 # direction
|
||||
|
||||
|
||||
def test_three_line_break_bars_reference():
|
||||
tlb = ta.ThreeLineBreakBars(3)
|
||||
assert tlb.update(10.0) == [] # seed
|
||||
assert tlb.update(11.0) == [(10.0, 11.0, 1)]
|
||||
|
||||
|
||||
def test_three_line_break_bars_batch_shape():
|
||||
tlb = ta.ThreeLineBreakBars(3)
|
||||
out = tlb.batch(np.array([10.0, 11.0, 12.0, 13.0]))
|
||||
assert out.shape[1] == 3
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
|
||||
|
||||
Wickra is a multi-language technical-analysis library with a Rust core and
|
||||
bindings for Python, Node.js, and WebAssembly. Every indicator is an O(1)
|
||||
bindings for Python, Node.js and WebAssembly, plus a C ABI for C/C++, C#, Go and any
|
||||
other C-capable language. Every indicator is an O(1)
|
||||
streaming state machine, so live trading dashboards and historical backtests
|
||||
share the exact same implementation. This package is the WebAssembly binding
|
||||
(wasm-bindgen, built for the `web` target); it exposes 200+ streaming-first
|
||||
@@ -54,8 +55,9 @@ the main repository and documentation site:
|
||||
- **Docs** (quickstarts, cookbook, TA-Lib migration): <https://docs.wickra.org>
|
||||
- **Runnable browser examples:** [`examples/wasm/`](https://github.com/wickra-lib/wickra/tree/main/examples/wasm)
|
||||
|
||||
Wickra ships four bindings — Python, Node.js, WebAssembly, and Rust — that all
|
||||
expose the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
Wickra ships native bindings for Python, Node.js, WebAssembly and Rust, plus a
|
||||
C ABI hub that any C-capable language (C, C++, Go, C#, Java, R) links against —
|
||||
all exposing the same indicators from the shared, `unsafe`-forbidden Rust core.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
||||
|
||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
use std::hint::black_box;
|
||||
use wickra::{Atr, BatchExt, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
|
||||
use wickra::{Atr, BollingerBands, Candle, Ema, Indicator, MacdIndicator, Rsi, Sma};
|
||||
use wickra_data::csv::CandleReader;
|
||||
use yata::prelude::Method;
|
||||
|
||||
@@ -82,7 +82,7 @@ fn sma_group(crit: &mut Criterion, closes: &[f64]) {
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = Sma::new(SMA_PERIOD).unwrap();
|
||||
black_box(ind.batch(series));
|
||||
black_box(ind.batch_nan(series));
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -170,7 +170,7 @@ fn ema_group(crit: &mut Criterion, closes: &[f64]) {
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = Ema::new(EMA_PERIOD).unwrap();
|
||||
black_box(ind.batch(series));
|
||||
black_box(ind.batch_nan(series));
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -253,7 +253,7 @@ fn rsi_group(crit: &mut Criterion, closes: &[f64]) {
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = Rsi::new(RSI_PERIOD).unwrap();
|
||||
black_box(ind.batch(series));
|
||||
black_box(ind.batch_nan(series));
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -352,7 +352,7 @@ fn macd_group(crit: &mut Criterion, closes: &[f64]) {
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = MacdIndicator::classic();
|
||||
black_box(ind.batch(series));
|
||||
black_box(ind.batch_macd(series));
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -478,7 +478,7 @@ fn bbands_group(crit: &mut Criterion, closes: &[f64]) {
|
||||
|bencher, &series| {
|
||||
bencher.iter(|| {
|
||||
let mut ind = BollingerBands::new(BB_PERIOD, BB_DEV).unwrap();
|
||||
black_box(ind.batch(series));
|
||||
black_box(ind.batch_bands(series));
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -604,9 +604,13 @@ fn atr_group(crit: &mut Criterion, candles: &[Candle]) {
|
||||
BenchmarkId::new("wickra/batch", len),
|
||||
&series,
|
||||
|bencher, &series| {
|
||||
// Column extraction is outside the timed loop, mirroring kand's arm.
|
||||
let high: Vec<f64> = series.iter().map(|candle| candle.high).collect();
|
||||
let low: Vec<f64> = series.iter().map(|candle| candle.low).collect();
|
||||
let close: Vec<f64> = series.iter().map(|candle| candle.close).collect();
|
||||
bencher.iter(|| {
|
||||
let mut ind = Atr::new(ATR_PERIOD).unwrap();
|
||||
black_box(ind.batch(series));
|
||||
black_box(ind.batch_atr(&high, &low, &close));
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Adaptive CCI — a CCI whose centre line adapts to the efficiency ratio.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Adaptive CCI — Lambert's Commodity Channel Index whose centre line is an
|
||||
/// **efficiency-ratio-adaptive** moving average of typical price instead of a
|
||||
/// plain SMA, so it leads in trends and stays calm in chop.
|
||||
///
|
||||
/// ```text
|
||||
/// TP = (high + low + close) / 3
|
||||
/// ER = |TP_t − TP_oldest| / Σ |ΔTP| over the window (0..1)
|
||||
/// sc = ( ER·(2/3 − 2/31) + 2/31 )²
|
||||
/// mean += sc·(TP_t − mean) (adaptive centre, seeded with SMA)
|
||||
/// MD = mean(|TP_i − mean|) over the window (mean deviation)
|
||||
/// CCI = (TP_t − mean) / (0.015 · MD)
|
||||
/// ```
|
||||
///
|
||||
/// The classic [`Cci`](crate::Cci) centres typical price on its simple moving
|
||||
/// average; the lag of that SMA delays the oscillator in fast moves. Replacing it
|
||||
/// with a KAMA-style adaptive average — driven by Kaufman's efficiency ratio —
|
||||
/// lets the centre line accelerate toward price in a clean trend (so the CCI
|
||||
/// reaches its `±100` bands sooner) and slow down in noise (fewer false pokes).
|
||||
/// The `0.015` scaling keeps Lambert's convention that roughly 70–80% of readings
|
||||
/// fall in `[−100, +100]`.
|
||||
///
|
||||
/// The output is unbounded around `0`; a flat window (zero mean deviation) returns
|
||||
/// `0`. The first value lands after `period` inputs; each `update` is O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AdaptiveCci};
|
||||
///
|
||||
/// let mut indicator = AdaptiveCci::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdaptiveCci {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
mean: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveCci {
|
||||
/// Construct an adaptive CCI with the given `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidPeriod`] if `period < 2` (the efficiency ratio needs a
|
||||
/// path of at least one step).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "adaptive CCI needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
mean: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdaptiveCci {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let tp = candle.typical_price();
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(tp);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
|
||||
// Efficiency ratio over the window.
|
||||
let oldest = self.window[0];
|
||||
let direction = (tp - oldest).abs();
|
||||
let mut path = 0.0;
|
||||
for pair in self.window.iter().collect::<Vec<_>>().windows(2) {
|
||||
path += (pair[1] - pair[0]).abs();
|
||||
}
|
||||
let er = if path > 0.0 {
|
||||
(direction / path).clamp(0.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let fast = 2.0 / 3.0;
|
||||
let slow = 2.0 / 31.0;
|
||||
let sc = (er * (fast - slow) + slow).powi(2);
|
||||
|
||||
let mean = match self.mean {
|
||||
None => self.window.iter().sum::<f64>() / n,
|
||||
Some(prev) => prev + sc * (tp - prev),
|
||||
};
|
||||
self.mean = Some(mean);
|
||||
|
||||
let md = self.window.iter().map(|&v| (v - mean).abs()).sum::<f64>() / n;
|
||||
let cci = if md > 0.0 {
|
||||
(tp - mean) / (0.015 * md)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(cci);
|
||||
Some(cci)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.mean = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdaptiveCci"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(tp: f64) -> Candle {
|
||||
// open=high=low=close=tp -> typical price == tp.
|
||||
Candle::new_unchecked(tp, tp, tp, tp, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(AdaptiveCci::new(0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
AdaptiveCci::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let c = AdaptiveCci::new(20).unwrap();
|
||||
assert_eq!(c.period(), 20);
|
||||
assert_eq!(c.warmup_period(), 20);
|
||||
assert_eq!(c.name(), "AdaptiveCci");
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut c = AdaptiveCci::new(4).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
let out = c.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_positive() {
|
||||
let mut c = AdaptiveCci::new(10).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last > 0.0, "uptrend should give positive CCI, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_is_negative() {
|
||||
let mut c = AdaptiveCci::new(10).unwrap();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(200.0 - f64::from(i))).collect();
|
||||
let last = c.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last < 0.0, "downtrend should give negative CCI, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_zero() {
|
||||
let mut c = AdaptiveCci::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..10).map(|_| candle(100.0)).collect();
|
||||
for v in c.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut c = AdaptiveCci::new(5).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(100.0 + f64::from(i))).collect();
|
||||
c.batch(&candles);
|
||||
assert!(c.is_ready());
|
||||
c.reset();
|
||||
assert!(!c.is_ready());
|
||||
assert_eq!(c.value(), None);
|
||||
assert_eq!(c.update(candle(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| candle(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
|
||||
.collect();
|
||||
let batch = AdaptiveCci::new(20).unwrap().batch(&candles);
|
||||
let mut b = AdaptiveCci::new(20).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Adaptive RSI — an RSI whose up/down averaging adapts to the efficiency ratio.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Adaptive RSI — Wilder's RSI in which the smoothing of the average gain and
|
||||
/// average loss **adapts to trendiness** via Kaufman's efficiency ratio, so the
|
||||
/// oscillator reacts fast in a clean move and smooths through chop.
|
||||
///
|
||||
/// ```text
|
||||
/// ER = |price_t − price_{t−period}| / Σ |Δprice| over the window (efficiency ratio, 0..1)
|
||||
/// sc = ( ER·(2/3 − 2/31) + 2/31 )² (KAMA smoothing constant)
|
||||
/// avg_gain += sc·(gain − avg_gain), avg_loss += sc·(loss − avg_loss)
|
||||
/// RSI = 100 · avg_gain / (avg_gain + avg_loss)
|
||||
/// ```
|
||||
///
|
||||
/// A fixed-period [`Rsi`](crate::Rsi) is a compromise: short periods whip in
|
||||
/// ranges, long ones lag in trends. This adaptive form borrows Kaufman's
|
||||
/// efficiency ratio (`directional move / total path`) to set the smoothing each
|
||||
/// bar — near `1` (a clean trend) the averages track gains and losses almost
|
||||
/// immediately; near `0` (noise) they barely move, filtering the chop. The result
|
||||
/// is an RSI that is responsive when it should be and quiet when it should be. It
|
||||
/// is the efficiency-ratio cousin of Ehlers' cycle-adaptive RSI, which instead
|
||||
/// sets the lookback from the measured dominant cycle.
|
||||
///
|
||||
/// Output is bounded in `[0, 100]`; a flat market returns the neutral `50`. The
|
||||
/// first value lands after `period + 1` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AdaptiveRsi};
|
||||
///
|
||||
/// let mut indicator = AdaptiveRsi::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdaptiveRsi {
|
||||
period: usize,
|
||||
prices: VecDeque<f64>,
|
||||
abs_changes: VecDeque<f64>,
|
||||
abs_sum: f64,
|
||||
prev: Option<f64>,
|
||||
seed_gain: f64,
|
||||
seed_loss: f64,
|
||||
seed_count: usize,
|
||||
avg_gain: Option<f64>,
|
||||
avg_loss: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AdaptiveRsi {
|
||||
/// Construct an adaptive RSI with the given efficiency-ratio `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prices: VecDeque::with_capacity(period + 1),
|
||||
abs_changes: VecDeque::with_capacity(period),
|
||||
abs_sum: 0.0,
|
||||
prev: None,
|
||||
seed_gain: 0.0,
|
||||
seed_loss: 0.0,
|
||||
seed_count: 0,
|
||||
avg_gain: None,
|
||||
avg_loss: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured efficiency-ratio period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
|
||||
let denom = avg_gain + avg_loss;
|
||||
if denom == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
100.0 * (avg_gain / denom)
|
||||
}
|
||||
}
|
||||
|
||||
fn efficiency_ratio(&self, price: f64) -> f64 {
|
||||
let oldest = *self.prices.front().expect("window non-empty");
|
||||
let direction = (price - oldest).abs();
|
||||
if self.abs_sum == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(direction / self.abs_sum).clamp(0.0, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdaptiveRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(price);
|
||||
self.prices.push_back(price);
|
||||
return None;
|
||||
};
|
||||
let change = price - prev;
|
||||
self.prev = Some(price);
|
||||
let gain = if change > 0.0 { change } else { 0.0 };
|
||||
let loss = if change < 0.0 { -change } else { 0.0 };
|
||||
|
||||
// Maintain the price window (period + 1) and the |Δ| window (period).
|
||||
self.prices.push_back(price);
|
||||
if self.prices.len() > self.period + 1 {
|
||||
self.prices.pop_front();
|
||||
}
|
||||
if self.abs_changes.len() == self.period {
|
||||
self.abs_sum -= self.abs_changes.pop_front().expect("non-empty");
|
||||
}
|
||||
self.abs_changes.push_back(change.abs());
|
||||
self.abs_sum += change.abs();
|
||||
|
||||
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
|
||||
let er = self.efficiency_ratio(price);
|
||||
let fast = 2.0 / 3.0;
|
||||
let slow = 2.0 / 31.0;
|
||||
let sc = (er * (fast - slow) + slow).powi(2);
|
||||
let new_ag = ag + sc * (gain - ag);
|
||||
let new_al = al + sc * (loss - al);
|
||||
self.avg_gain = Some(new_ag);
|
||||
self.avg_loss = Some(new_al);
|
||||
let v = Self::rsi_from_avgs(new_ag, new_al);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
self.seed_gain += gain;
|
||||
self.seed_loss += loss;
|
||||
self.seed_count += 1;
|
||||
if self.seed_count == self.period {
|
||||
let ag = self.seed_gain / self.period as f64;
|
||||
let al = self.seed_loss / self.period as f64;
|
||||
self.avg_gain = Some(ag);
|
||||
self.avg_loss = Some(al);
|
||||
let v = Self::rsi_from_avgs(ag, al);
|
||||
self.last = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prices.clear();
|
||||
self.abs_changes.clear();
|
||||
self.abs_sum = 0.0;
|
||||
self.prev = None;
|
||||
self.seed_gain = 0.0;
|
||||
self.seed_loss = 0.0;
|
||||
self.seed_count = 0;
|
||||
self.avg_gain = None;
|
||||
self.avg_loss = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdaptiveRsi"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(AdaptiveRsi::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let r = AdaptiveRsi::new(14).unwrap();
|
||||
assert_eq!(r.period(), 14);
|
||||
assert_eq!(r.warmup_period(), 15);
|
||||
assert_eq!(r.name(), "AdaptiveRsi");
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let out = r.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_one_hundred() {
|
||||
let mut r = AdaptiveRsi::new(5).unwrap();
|
||||
let last = r
|
||||
.batch(&(1..=40).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let last = r.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut r = AdaptiveRsi::new(14).unwrap();
|
||||
for v in r
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 8.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((0.0..=100.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
let ready = r
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(r.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut r = AdaptiveRsi::new(4).unwrap();
|
||||
r.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(r.is_ready());
|
||||
r.reset();
|
||||
assert!(!r.is_ready());
|
||||
assert_eq!(r.value(), None);
|
||||
assert_eq!(r.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = AdaptiveRsi::new(14).unwrap().batch(&xs);
|
||||
let mut b = AdaptiveRsi::new(14).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Andrews Pitchfork — median line and parallels off the last three swing pivots.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`AndrewsPitchfork`]: the three pitchfork lines projected to the
|
||||
/// current bar.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AndrewsPitchforkOutput {
|
||||
/// The median line — from the handle pivot through the midpoint of the other two.
|
||||
pub median: f64,
|
||||
/// The upper parallel (through the higher of the two anchor pivots).
|
||||
pub upper: f64,
|
||||
/// The lower parallel (through the lower of the two anchor pivots).
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// A confirmed swing pivot: its bar index and price.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Pivot {
|
||||
index: f64,
|
||||
price: f64,
|
||||
is_high: bool,
|
||||
}
|
||||
|
||||
/// Andrews Pitchfork — Alan Andrews' median-line tool drawn from the three most
|
||||
/// recent **swing pivots**, projected forward to the current bar.
|
||||
///
|
||||
/// ```text
|
||||
/// detect alternating swing highs/lows with a `strength`-bar fractal
|
||||
/// P0 = handle (oldest of the last three), P1, P2 = the next two
|
||||
/// M = midpoint of P1 and P2
|
||||
/// median(t) = P0 + slope·(t − t0) slope = (M − P0) / (M_t − t0)
|
||||
/// upper / lower = median(t) offset by the vertical gap to the higher / lower anchor
|
||||
/// ```
|
||||
///
|
||||
/// The pitchfork projects a "fork" of three parallel lines: a central **median
|
||||
/// line** drawn from a starting pivot through the midpoint of a later swing, plus
|
||||
/// two parallels passing through that swing's high and low. Price tends to
|
||||
/// oscillate around the median line and find support/resistance at the parallels.
|
||||
/// This streaming version detects the pivots automatically with a symmetric
|
||||
/// fractal of half-width `strength` (so each pivot is confirmed `strength` bars
|
||||
/// late) and keeps the three most recent alternating swings.
|
||||
///
|
||||
/// Because it depends on swing structure, readiness is **data-dependent**: the
|
||||
/// first output appears once three alternating pivots have been confirmed.
|
||||
/// `warmup_period` returns the minimum bars to confirm a single pivot. Each
|
||||
/// `update` is O(`strength`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AndrewsPitchfork};
|
||||
///
|
||||
/// let mut indicator = AndrewsPitchfork::new(2).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.4).sin() * 10.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// // A swinging series eventually establishes a pitchfork.
|
||||
/// let _ = last;
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AndrewsPitchfork {
|
||||
strength: usize,
|
||||
window: VecDeque<Candle>,
|
||||
pivots: Vec<Pivot>,
|
||||
count: usize,
|
||||
last: Option<AndrewsPitchforkOutput>,
|
||||
}
|
||||
|
||||
impl AndrewsPitchfork {
|
||||
/// Construct an Andrews Pitchfork with the given fractal `strength` (bars on
|
||||
/// each side of a pivot).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `strength == 0`.
|
||||
pub fn new(strength: usize) -> Result<Self> {
|
||||
if strength == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
strength,
|
||||
window: VecDeque::with_capacity(2 * strength + 1),
|
||||
pivots: Vec::new(),
|
||||
count: 0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured fractal strength.
|
||||
pub const fn strength(&self) -> usize {
|
||||
self.strength
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<AndrewsPitchforkOutput> {
|
||||
self.last
|
||||
}
|
||||
|
||||
/// Record a freshly confirmed pivot, keeping the last three alternating swings.
|
||||
fn record_pivot(&mut self, pivot: Pivot) {
|
||||
if let Some(last) = self.pivots.last_mut() {
|
||||
if last.is_high == pivot.is_high {
|
||||
// Same kind: keep the more extreme one (and its index).
|
||||
let more_extreme = if pivot.is_high {
|
||||
pivot.price > last.price
|
||||
} else {
|
||||
pivot.price < last.price
|
||||
};
|
||||
if more_extreme {
|
||||
*last = pivot;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.pivots.push(pivot);
|
||||
if self.pivots.len() > 3 {
|
||||
self.pivots.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn project(&self, tc: f64) -> Option<AndrewsPitchforkOutput> {
|
||||
let [p0, p1, p2] = self.pivots.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
let mid_t = f64::midpoint(p1.index, p2.index);
|
||||
let mid_p = f64::midpoint(p1.price, p2.price);
|
||||
let slope = (mid_p - p0.price) / (mid_t - p0.index);
|
||||
let median = p0.price + slope * (tc - p0.index);
|
||||
let off1 = p1.price - (p0.price + slope * (p1.index - p0.index));
|
||||
let off2 = p2.price - (p0.price + slope * (p2.index - p0.index));
|
||||
Some(AndrewsPitchforkOutput {
|
||||
median,
|
||||
upper: median + off1.max(off2),
|
||||
lower: median + off1.min(off2),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AndrewsPitchfork {
|
||||
type Input = Candle;
|
||||
type Output = AndrewsPitchforkOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AndrewsPitchforkOutput> {
|
||||
self.count += 1;
|
||||
let span = 2 * self.strength + 1;
|
||||
if self.window.len() == span {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(candle);
|
||||
if self.window.len() == span {
|
||||
let center = self.window[self.strength];
|
||||
let is_high = self
|
||||
.window
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, c)| i == self.strength || c.high < center.high);
|
||||
let is_low = self
|
||||
.window
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, c)| i == self.strength || c.low > center.low);
|
||||
// Absolute index of the center bar (1-based count minus the right span).
|
||||
let center_index = (self.count - 1 - self.strength) as f64;
|
||||
if is_high && !is_low {
|
||||
self.record_pivot(Pivot {
|
||||
index: center_index,
|
||||
price: center.high,
|
||||
is_high: true,
|
||||
});
|
||||
} else if is_low && !is_high {
|
||||
self.record_pivot(Pivot {
|
||||
index: center_index,
|
||||
price: center.low,
|
||||
is_high: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
let tc = (self.count - 1) as f64;
|
||||
if let Some(out) = self.project(tc) {
|
||||
self.last = Some(out);
|
||||
return Some(out);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.pivots.clear();
|
||||
self.count = 0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2 * self.strength + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AndrewsPitchfork"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
f64::midpoint(high, low),
|
||||
high,
|
||||
low,
|
||||
f64::midpoint(high, low),
|
||||
1_000.0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
/// A clean zig-zag that prints alternating swing highs and lows.
|
||||
fn zigzag() -> Vec<Candle> {
|
||||
let mut out = Vec::new();
|
||||
for i in 0..120 {
|
||||
let base = 100.0 + (f64::from(i) * 0.5).sin() * 10.0;
|
||||
out.push(c(base + 1.0, base - 1.0));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_strength() {
|
||||
assert!(matches!(AndrewsPitchfork::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = AndrewsPitchfork::new(2).unwrap();
|
||||
assert_eq!(p.strength(), 2);
|
||||
assert_eq!(p.warmup_period(), 5);
|
||||
assert_eq!(p.name(), "AndrewsPitchfork");
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_before_three_pivots() {
|
||||
let mut p = AndrewsPitchfork::new(2).unwrap();
|
||||
// Too few bars to ever confirm three alternating pivots.
|
||||
let out = p.batch(&[c(101.0, 99.0), c(102.0, 100.0), c(101.0, 99.0)]);
|
||||
assert!(out.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eventually_emits_on_swings() {
|
||||
let mut p = AndrewsPitchfork::new(2).unwrap();
|
||||
let out = p.batch(&zigzag());
|
||||
assert!(
|
||||
out.iter().any(Option::is_some),
|
||||
"a swinging series should form a pitchfork"
|
||||
);
|
||||
assert!(p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_at_or_above_lower() {
|
||||
let mut p = AndrewsPitchfork::new(2).unwrap();
|
||||
for o in p.batch(&zigzag()).into_iter().flatten() {
|
||||
assert!(
|
||||
o.upper >= o.lower,
|
||||
"upper {} below lower {}",
|
||||
o.upper,
|
||||
o.lower
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = AndrewsPitchfork::new(2).unwrap();
|
||||
p.batch(&zigzag());
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
assert_eq!(p.strength(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_pivot_keeps_more_extreme_same_kind() {
|
||||
let mut p = AndrewsPitchfork::new(2).unwrap();
|
||||
p.record_pivot(Pivot {
|
||||
index: 0.0,
|
||||
price: 100.0,
|
||||
is_high: true,
|
||||
});
|
||||
// A higher high of the same kind replaces the stored one.
|
||||
p.record_pivot(Pivot {
|
||||
index: 1.0,
|
||||
price: 105.0,
|
||||
is_high: true,
|
||||
});
|
||||
assert_eq!(p.pivots.len(), 1);
|
||||
assert_eq!(p.pivots[0].price, 105.0);
|
||||
// A lower high of the same kind is ignored.
|
||||
p.record_pivot(Pivot {
|
||||
index: 2.0,
|
||||
price: 102.0,
|
||||
is_high: true,
|
||||
});
|
||||
assert_eq!(p.pivots.len(), 1);
|
||||
assert_eq!(p.pivots[0].price, 105.0);
|
||||
// A low pivot of the other kind is appended.
|
||||
p.record_pivot(Pivot {
|
||||
index: 3.0,
|
||||
price: 90.0,
|
||||
is_high: false,
|
||||
});
|
||||
assert_eq!(p.pivots.len(), 2);
|
||||
// A lower low of the same kind replaces the stored low.
|
||||
p.record_pivot(Pivot {
|
||||
index: 4.0,
|
||||
price: 85.0,
|
||||
is_high: false,
|
||||
});
|
||||
assert_eq!(p.pivots[1].price, 85.0);
|
||||
// A higher low of the same kind is ignored.
|
||||
p.record_pivot(Pivot {
|
||||
index: 5.0,
|
||||
price: 88.0,
|
||||
is_high: false,
|
||||
});
|
||||
assert_eq!(p.pivots[1].price, 85.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = zigzag();
|
||||
let batch = AndrewsPitchfork::new(2).unwrap().batch(&candles);
|
||||
let mut b = AndrewsPitchfork::new(2).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,67 @@ impl Atr {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Vectorized batch over raw high/low/close columns: one `f64` per bar
|
||||
/// (`NaN` during warmup). The caller guarantees the three slices are equal
|
||||
/// length and finite with valid OHLC ordering (the binding validates once up
|
||||
/// front); ATR only reads high, low and the previous close.
|
||||
///
|
||||
/// For a fresh indicator long enough to seed (`n >= period`) it runs the
|
||||
/// true-range seed once and then the bare Wilder recurrence in a tight loop —
|
||||
/// no per-bar `Candle` construction/validation, no `Option`, identical
|
||||
/// division at the seed and `mul_add` afterwards, so the result is
|
||||
/// *bit-for-bit* equal to replaying `update` over the same candles. Shorter
|
||||
/// or non-fresh inputs defer to an exact `update` replay.
|
||||
pub fn batch_atr(&mut self, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
let p = self.period;
|
||||
let n = high.len();
|
||||
if self.seeded || !self.seed_buf.is_empty() || self.prev_close.is_some() || n < p {
|
||||
let mut out = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
|
||||
if let Some(v) = self.update(candle) {
|
||||
out[i] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Warmup `[0, p-1)` is `NaN`; the first ATR is emitted at index `p - 1`.
|
||||
let mut out = vec![f64::NAN; p - 1];
|
||||
out.reserve(n - (p - 1));
|
||||
// Seed: mean of the first `period` true ranges. TR₀ has no previous close.
|
||||
let mut prev_close = close[0];
|
||||
let mut sum_tr = high[0] - low[0];
|
||||
self.seed_buf.push(sum_tr);
|
||||
for i in 1..p {
|
||||
let (h, l) = (high[i], low[i]);
|
||||
let tr = (h - l)
|
||||
.max((h - prev_close).abs())
|
||||
.max((l - prev_close).abs());
|
||||
prev_close = close[i];
|
||||
self.seed_buf.push(tr);
|
||||
sum_tr += tr;
|
||||
}
|
||||
let mut avg = sum_tr / p as f64;
|
||||
out.push(avg);
|
||||
// Steady state: Wilder smoothing, reciprocal hoisted out of the loop.
|
||||
for i in p..n {
|
||||
let (h, l) = (high[i], low[i]);
|
||||
let tr = (h - l)
|
||||
.max((h - prev_close).abs())
|
||||
.max((l - prev_close).abs());
|
||||
prev_close = close[i];
|
||||
avg = avg.mul_add(self.n_minus_1, tr) * self.inv_period;
|
||||
out.push(avg);
|
||||
}
|
||||
|
||||
// Leave state where a full `update` replay would (seeded; seed_buf retained).
|
||||
self.prev_close = Some(prev_close);
|
||||
self.avg = avg;
|
||||
self.seeded = true;
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Atr {
|
||||
@@ -266,6 +327,81 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter()
|
||||
.zip(b)
|
||||
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
||||
}
|
||||
|
||||
fn atr_replay(period: usize, high: &[f64], low: &[f64], close: &[f64]) -> Vec<f64> {
|
||||
let mut a = Atr::new(period).unwrap();
|
||||
(0..high.len())
|
||||
.map(|i| {
|
||||
let candle = Candle::new_unchecked(close[i], high[i], low[i], close[i], 0.0, 0);
|
||||
a.update(candle).unwrap_or(f64::NAN)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Valid OHLC columns from a wandering base price.
|
||||
fn columns(n: usize) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
|
||||
let base: Vec<f64> = (0..n)
|
||||
.map(|i| (f64::from(u32::try_from(i).unwrap()) * 0.3).sin() * 5.0 + 100.0)
|
||||
.collect();
|
||||
let high = base.iter().map(|b| b + 1.0).collect();
|
||||
let low = base.iter().map(|b| b - 1.0).collect();
|
||||
(high, low, base)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_atr_fast_path_is_bit_identical() {
|
||||
let (high, low, close) = columns(300);
|
||||
let mut atr = Atr::new(14).unwrap();
|
||||
let got = atr.batch_atr(&high, &low, &close);
|
||||
assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
|
||||
let mut ref_atr = Atr::new(14).unwrap();
|
||||
for i in 0..high.len() {
|
||||
ref_atr.update(Candle::new_unchecked(
|
||||
close[i], high[i], low[i], close[i], 0.0, 0,
|
||||
));
|
||||
}
|
||||
let next = Candle::new_unchecked(101.0, 102.0, 100.0, 101.0, 0.0, 0);
|
||||
assert_eq!(atr.update(next), ref_atr.update(next));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_atr_falls_back_when_not_fresh() {
|
||||
let (high, low, close) = columns(40);
|
||||
let mut atr = Atr::new(14).unwrap();
|
||||
atr.update(Candle::new_unchecked(
|
||||
close[0], high[0], low[0], close[0], 0.0, 0,
|
||||
));
|
||||
let mut ref_atr = Atr::new(14).unwrap();
|
||||
ref_atr.update(Candle::new_unchecked(
|
||||
close[0], high[0], low[0], close[0], 0.0, 0,
|
||||
));
|
||||
let want: Vec<f64> = (0..high.len())
|
||||
.map(|i| {
|
||||
ref_atr
|
||||
.update(Candle::new_unchecked(
|
||||
close[i], high[i], low[i], close[i], 0.0, 0,
|
||||
))
|
||||
.unwrap_or(f64::NAN)
|
||||
})
|
||||
.collect();
|
||||
assert!(bits_eq(&atr.batch_atr(&high, &low, &close), &want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_atr_sub_period_slice_falls_back() {
|
||||
let (high, low, close) = columns(5);
|
||||
let mut atr = Atr::new(14).unwrap();
|
||||
let got = atr.batch_atr(&high, &low, &close);
|
||||
assert!(bits_eq(&got, &atr_replay(14, &high, &low, &close)));
|
||||
assert!(got.iter().all(|x| x.is_nan()));
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Ehlers Autocorrelation Periodogram — estimates the dominant market cycle.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::roofing_filter::RoofingFilter;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Number of bars averaged into each lagged correlation (Ehlers' `AvgLength`).
|
||||
const AVG_LENGTH: usize = 3;
|
||||
|
||||
/// Ehlers' **Autocorrelation Periodogram** — measures the **dominant cycle
|
||||
/// period** of the market by correlating a roofing-filtered price with lagged
|
||||
/// copies of itself and reading off the spectral peak.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 8):
|
||||
///
|
||||
/// ```text
|
||||
/// Filt = RoofingFilter(price) (detrend + denoise)
|
||||
/// Corr[lag] = Pearson( Filt[0..AvgLength], Filt[lag..lag+AvgLength] ) for lag = 0..max_period
|
||||
/// for each candidate period:
|
||||
/// power[period] = (Σ Corr[N]·cos(2πN/period))² + (Σ Corr[N]·sin(2πN/period))²
|
||||
/// R[period] = 0.2·power[period] + 0.8·R[period]_{t−1} (EMA across time)
|
||||
/// normalise by a decaying max, then
|
||||
/// DominantCycle = centre-of-gravity of periods whose normalised power ≥ 0.5
|
||||
/// ```
|
||||
///
|
||||
/// The autocorrelation function emphasises whatever cycle is actually present and
|
||||
/// suppresses noise; transforming it into a periodogram and taking the
|
||||
/// power-weighted centre of gravity gives a smooth, robust estimate of the
|
||||
/// dominant cycle length. That cycle is the key input for every *adaptive*
|
||||
/// indicator (adaptive RSI/CCI/stochastic) — set their lookback from it. The
|
||||
/// output is a period in bars within `[min_period, max_period]`.
|
||||
///
|
||||
/// The first value lands after `max_period + AvgLength` inputs. Each `update` is
|
||||
/// O(`max_period²`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, AutocorrelationPeriodogram};
|
||||
/// use std::f64::consts::TAU;
|
||||
///
|
||||
/// let mut indicator = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = indicator.update(100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutocorrelationPeriodogram {
|
||||
min_period: usize,
|
||||
max_period: usize,
|
||||
roof: RoofingFilter,
|
||||
buffer: VecDeque<f64>,
|
||||
r: Vec<f64>,
|
||||
max_pwr: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl AutocorrelationPeriodogram {
|
||||
/// Construct an autocorrelation periodogram searching cycles in
|
||||
/// `[min_period, max_period]`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either period is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `min_period < AvgLength + 1` or
|
||||
/// `max_period <= min_period`.
|
||||
pub fn new(min_period: usize, max_period: usize) -> Result<Self> {
|
||||
if min_period == 0 || max_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if min_period < AVG_LENGTH + 1 || max_period <= min_period {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "autocorrelation periodogram needs AvgLength < min_period < max_period",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
min_period,
|
||||
max_period,
|
||||
roof: RoofingFilter::new(10, max_period)?,
|
||||
buffer: VecDeque::with_capacity(max_period + AVG_LENGTH),
|
||||
r: vec![0.0; max_period + 1],
|
||||
max_pwr: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(min_period, max_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.min_period, self.max_period)
|
||||
}
|
||||
|
||||
/// Current dominant-cycle estimate if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
/// Pearson correlation of the `AvgLength`-deep slices offset by `lag`.
|
||||
/// `buffer` is newest-last; `filt(k)` is the value `k` bars back.
|
||||
fn correlation(&self, lag: usize) -> f64 {
|
||||
let len = self.buffer.len();
|
||||
let filt = |k: usize| self.buffer[len - 1 - k];
|
||||
let m = AVG_LENGTH as f64;
|
||||
let (mut sx, mut sy, mut sxx, mut syy, mut sxy) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for count in 0..AVG_LENGTH {
|
||||
let x = filt(count);
|
||||
let y = filt(lag + count);
|
||||
sx += x;
|
||||
sy += y;
|
||||
sxx += x * x;
|
||||
syy += y * y;
|
||||
sxy += x * y;
|
||||
}
|
||||
let denom = (m * sxx - sx * sx) * (m * syy - sy * sy);
|
||||
if denom > 0.0 {
|
||||
(m * sxy - sx * sy) / denom.sqrt()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AutocorrelationPeriodogram {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let filt = self.roof.update(price)?;
|
||||
if self.buffer.len() == self.max_period + AVG_LENGTH {
|
||||
self.buffer.pop_front();
|
||||
}
|
||||
self.buffer.push_back(filt);
|
||||
if self.buffer.len() < self.max_period + AVG_LENGTH {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Autocorrelation across lags.
|
||||
let mut corr = vec![0.0; self.max_period + 1];
|
||||
for (lag, c) in corr.iter_mut().enumerate() {
|
||||
*c = self.correlation(lag);
|
||||
}
|
||||
|
||||
// Periodogram: spectral power for each candidate period, EMA'd over time.
|
||||
self.max_pwr *= 0.995;
|
||||
for period in self.min_period..=self.max_period {
|
||||
let mut cosine = 0.0;
|
||||
let mut sine = 0.0;
|
||||
for (n, &cn) in corr
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take(self.max_period + 1)
|
||||
.skip(AVG_LENGTH)
|
||||
{
|
||||
let angle = TAU * n as f64 / period as f64;
|
||||
cosine += cn * angle.cos();
|
||||
sine += cn * angle.sin();
|
||||
}
|
||||
let power = cosine * cosine + sine * sine;
|
||||
self.r[period] = 0.2 * power + 0.8 * self.r[period];
|
||||
if self.r[period] > self.max_pwr {
|
||||
self.max_pwr = self.r[period];
|
||||
}
|
||||
}
|
||||
|
||||
// Power-weighted centre of gravity of the strong periods.
|
||||
let mut spx = 0.0;
|
||||
let mut sp = 0.0;
|
||||
for period in self.min_period..=self.max_period {
|
||||
let pwr = if self.max_pwr > 0.0 {
|
||||
self.r[period] / self.max_pwr
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if pwr >= 0.5 {
|
||||
spx += period as f64 * pwr;
|
||||
sp += pwr;
|
||||
}
|
||||
}
|
||||
let dominant = if sp > 0.0 {
|
||||
(spx / sp).clamp(self.min_period as f64, self.max_period as f64)
|
||||
} else {
|
||||
self.min_period as f64
|
||||
};
|
||||
self.last = Some(dominant);
|
||||
Some(dominant)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.roof.reset();
|
||||
self.buffer.clear();
|
||||
self.r.iter_mut().for_each(|x| *x = 0.0);
|
||||
self.max_pwr = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.max_period + AVG_LENGTH
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AutocorrelationPeriodogram"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(0, 48),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(3, 48),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
AutocorrelationPeriodogram::new(48, 10),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
assert_eq!(p.periods(), (10, 48));
|
||||
assert_eq!(p.warmup_period(), 51);
|
||||
assert_eq!(p.name(), "AutocorrelationPeriodogram");
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut p = AutocorrelationPeriodogram::new(8, 20).unwrap();
|
||||
let xs: Vec<f64> = (0..40)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 12.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out = p.batch(&xs);
|
||||
let warmup = p.warmup_period(); // 23
|
||||
assert_eq!(warmup, 23);
|
||||
for v in out.iter().take(warmup - 1) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_within_period_band() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
for v in p.batch(&xs).into_iter().flatten() {
|
||||
assert!((10.0..=48.0).contains(&v), "cycle out of band: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_injected_cycle() {
|
||||
// A clean 20-bar sine: the dominant cycle estimate should settle near 20.
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let xs: Vec<f64> = (0..600)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let last = p.batch(&xs).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
(last - 20.0).abs() < 6.0,
|
||||
"expected ~20-bar cycle, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
p.batch(
|
||||
&(0..80)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let before = p.value();
|
||||
assert_eq!(p.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
p.batch(
|
||||
&(0..120)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (TAU * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let batch = AutocorrelationPeriodogram::new(10, 48).unwrap().batch(&xs);
|
||||
let mut b = AutocorrelationPeriodogram::new(10, 48).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_input_falls_back_to_min_period() {
|
||||
// Constant input has zero variance, so every lag correlation is
|
||||
// degenerate (denom <= 0), the max power is zero and no period clears
|
||||
// the 0.5 threshold -> the dominant cycle defaults to `min_period`.
|
||||
let flat = [100.0_f64; 200];
|
||||
let last = AutocorrelationPeriodogram::new(10, 48)
|
||||
.unwrap()
|
||||
.batch(&flat)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 10.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Ehlers Bandpass Filter — isolates the cyclic component around a target period.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' Bandpass Filter — a two-pole resonator that passes the cyclic content
|
||||
/// around a target `period` and rejects both the trend (low frequencies) and the
|
||||
/// noise (high frequencies).
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
|
||||
///
|
||||
/// ```text
|
||||
/// beta = cos(2π / period)
|
||||
/// gamma = 1 / cos(4π · bandwidth / period)
|
||||
/// alpha = gamma − sqrt(gamma² − 1)
|
||||
/// BP_t = 0.5·(1 − alpha)·(price_t − price_{t−2})
|
||||
/// + beta·(1 + alpha)·BP_{t−1} − alpha·BP_{t−2}
|
||||
/// ```
|
||||
///
|
||||
/// `bandwidth` (a fraction, typically `0.3`) sets how wide a band of periods is
|
||||
/// admitted: narrow bandwidth gives a sharp, ringing resonator tuned tightly to
|
||||
/// `period`; wide bandwidth lets more of the spectrum through. The output is a
|
||||
/// zero-mean oscillator — it swings symmetrically around `0`, peaking when the
|
||||
/// dominant cycle aligns with `period`. It is the building block for cycle-phase
|
||||
/// and cycle-amplitude work.
|
||||
///
|
||||
/// The recursion needs two prior prices and two prior outputs; until then it emits
|
||||
/// `0` (Ehlers' initial condition), so `warmup_period` is `1` and a value is
|
||||
/// produced every bar. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BandpassFilter};
|
||||
///
|
||||
/// let mut indicator = BandpassFilter::new(20, 0.3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BandpassFilter {
|
||||
period: usize,
|
||||
bandwidth: f64,
|
||||
beta: f64,
|
||||
alpha: f64,
|
||||
prev_price_1: Option<f64>,
|
||||
prev_price_2: Option<f64>,
|
||||
bp1: f64,
|
||||
bp2: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl BandpassFilter {
|
||||
/// Construct a bandpass filter tuned to `period` with the given `bandwidth`
|
||||
/// fraction.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidParameter`] if `bandwidth` is not finite or outside
|
||||
/// `(0, 1)`.
|
||||
pub fn new(period: usize, bandwidth: f64) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !bandwidth.is_finite() || bandwidth <= 0.0 || bandwidth >= 1.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "bandpass bandwidth must be in (0, 1)",
|
||||
});
|
||||
}
|
||||
let period_f = period as f64;
|
||||
let beta = (2.0 * PI / period_f).cos();
|
||||
let gamma = 1.0 / (4.0 * PI * bandwidth / period_f).cos();
|
||||
let alpha = gamma - (gamma * gamma - 1.0).sqrt();
|
||||
Ok(Self {
|
||||
period,
|
||||
bandwidth,
|
||||
beta,
|
||||
alpha,
|
||||
prev_price_1: None,
|
||||
prev_price_2: None,
|
||||
bp1: 0.0,
|
||||
bp2: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bandwidth)`.
|
||||
pub const fn params(&self) -> (usize, f64) {
|
||||
(self.period, self.bandwidth)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BandpassFilter {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let bp = match self.prev_price_2 {
|
||||
Some(p2) => {
|
||||
0.5 * (1.0 - self.alpha) * (price - p2) + self.beta * (1.0 + self.alpha) * self.bp1
|
||||
- self.alpha * self.bp2
|
||||
}
|
||||
None => 0.0,
|
||||
};
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
self.bp2 = self.bp1;
|
||||
self.bp1 = bp;
|
||||
self.last = Some(bp);
|
||||
Some(bp)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price_1 = None;
|
||||
self.prev_price_2 = None;
|
||||
self.bp1 = 0.0;
|
||||
self.bp2 = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BandpassFilter"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(0, 0.3),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(20, 0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
BandpassFilter::new(20, 1.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
assert_eq!(bp.params(), (20, 0.3));
|
||||
assert_eq!(bp.warmup_period(), 1);
|
||||
assert_eq!(bp.name(), "BandpassFilter");
|
||||
assert!(!bp.is_ready());
|
||||
assert_eq!(bp.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bars_are_zero() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
assert_eq!(bp.update(100.0), Some(0.0));
|
||||
assert_eq!(bp.update(101.0), Some(0.0));
|
||||
// From the third bar the recursion is active.
|
||||
assert!(bp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_stays_zero() {
|
||||
// A trend-free flat input has no cyclic content -> output stays 0.
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
for v in bp.batch(&[50.0; 200]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_input_oscillates_around_zero() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (2.0 * PI * f64::from(i) / 20.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out: Vec<f64> = bp.batch(&xs).into_iter().flatten().skip(100).collect();
|
||||
let mean = out.iter().sum::<f64>() / out.len() as f64;
|
||||
assert!(
|
||||
mean.abs() < 1.0,
|
||||
"bandpass output should be ~zero mean, got {mean}"
|
||||
);
|
||||
assert!(out.iter().any(|&v| v > 0.5));
|
||||
assert!(out.iter().any(|&v| v < -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = bp.value();
|
||||
assert_eq!(bp.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bp = BandpassFilter::new(20, 0.3).unwrap();
|
||||
bp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(bp.is_ready());
|
||||
bp.reset();
|
||||
assert!(!bp.is_ready());
|
||||
assert_eq!(bp.update(100.0), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = BandpassFilter::new(20, 0.3).unwrap().batch(&xs);
|
||||
let mut b = BandpassFilter::new(20, 0.3).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Better Volume (VSA) — a streaming effort-versus-result oscillator.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Better Volume — a Volume-Spread-Analysis (VSA) "effort versus result"
|
||||
/// oscillator: how much volume (effort) a bar spent relative to the price range
|
||||
/// (result) it achieved, both normalised against their own recent averages.
|
||||
///
|
||||
/// ```text
|
||||
/// range_t = high_t − low_t
|
||||
/// rel_vol = volume_t / SMA(volume, period)
|
||||
/// rel_range = range_t / SMA(range, period)
|
||||
/// BetterVol = rel_vol − rel_range
|
||||
/// ```
|
||||
///
|
||||
/// Volume-Spread Analysis (Wyckoff, popularised by Tom Williams) reads markets
|
||||
/// through the relationship between **effort** (volume) and **result** (the bar's
|
||||
/// spread). A bar with heavy volume but a narrow range — `rel_vol` high while
|
||||
/// `rel_range` low, so the oscillator is **positive** — is *churn*: large effort
|
||||
/// produced little movement, the hallmark of absorption (supply meeting demand at
|
||||
/// a top, or vice versa at a bottom). A bar that travels far on light volume —
|
||||
/// negative oscillator — shows *ease of movement*, a trend meeting no resistance.
|
||||
///
|
||||
/// Both legs are normalised by their `period` simple moving averages (including
|
||||
/// the current bar), so the output is centred near `0` and self-scales to the
|
||||
/// instrument. A degenerate average of `0` makes its leg `0` rather than dividing
|
||||
/// by zero. The first value lands after `period` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, BetterVolume};
|
||||
///
|
||||
/// let mut indicator = BetterVolume::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BetterVolume {
|
||||
period: usize,
|
||||
volumes: VecDeque<f64>,
|
||||
ranges: VecDeque<f64>,
|
||||
vol_sum: f64,
|
||||
range_sum: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl BetterVolume {
|
||||
/// Construct a new Better Volume oscillator with the given averaging `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
volumes: VecDeque::with_capacity(period),
|
||||
ranges: VecDeque::with_capacity(period),
|
||||
vol_sum: 0.0,
|
||||
range_sum: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured averaging period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BetterVolume {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
if self.volumes.len() == self.period {
|
||||
self.vol_sum -= self.volumes.pop_front().expect("non-empty");
|
||||
self.range_sum -= self.ranges.pop_front().expect("non-empty");
|
||||
}
|
||||
self.volumes.push_back(candle.volume);
|
||||
self.ranges.push_back(range);
|
||||
self.vol_sum += candle.volume;
|
||||
self.range_sum += range;
|
||||
if self.volumes.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let sma_vol = self.vol_sum / n;
|
||||
let sma_range = self.range_sum / n;
|
||||
let rel_vol = if sma_vol > 0.0 {
|
||||
candle.volume / sma_vol
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let rel_range = if sma_range > 0.0 {
|
||||
range / sma_range
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let out = rel_vol - rel_range;
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.volumes.clear();
|
||||
self.ranges.clear();
|
||||
self.vol_sum = 0.0;
|
||||
self.range_sum = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BetterVolume"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, high, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bv = BetterVolume::new(20).unwrap();
|
||||
assert_eq!(bv.period(), 20);
|
||||
assert_eq!(bv.warmup_period(), 20);
|
||||
assert_eq!(bv.name(), "BetterVolume");
|
||||
assert!(!bv.is_ready());
|
||||
assert_eq!(bv.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut bv = BetterVolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
|
||||
let out = bv.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_bars_are_neutral() {
|
||||
// Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0.
|
||||
let mut bv = BetterVolume::new(4).unwrap();
|
||||
let candles: Vec<Candle> = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
|
||||
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn churn_bar_is_positive() {
|
||||
// Three normal bars, then a high-volume narrow-range bar -> positive.
|
||||
let mut bv = BetterVolume::new(4).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect();
|
||||
candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range
|
||||
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(last > 0.0, "churn bar should be positive, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ease_of_movement_bar_is_negative() {
|
||||
// Three normal bars, then a wide-range light-volume bar -> negative.
|
||||
let mut bv = BetterVolume::new(4).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect();
|
||||
candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume
|
||||
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last < 0.0,
|
||||
"ease-of-movement bar should be negative, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_everything_is_zero() {
|
||||
// Zero volume and zero range -> both legs guarded to 0.
|
||||
let mut bv = BetterVolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect();
|
||||
for v in bv.batch(&candles).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bv = BetterVolume::new(3).unwrap();
|
||||
bv.batch(
|
||||
&(0..6)
|
||||
.map(|_| candle(102.0, 100.0, 1_000.0))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(bv.is_ready());
|
||||
bv.reset();
|
||||
assert!(!bv.is_ready());
|
||||
assert_eq!(bv.value(), None);
|
||||
assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
candle(
|
||||
base + 2.0,
|
||||
base - 1.5,
|
||||
1_000.0 + (f64::from(i) * 0.5).cos() * 400.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = BetterVolume::new(20).unwrap().batch(&candles);
|
||||
let mut b = BetterVolume::new(20).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,82 @@ impl BollingerBands {
|
||||
self.multiplier
|
||||
}
|
||||
|
||||
/// Vectorized flat batch for bindings: returns `n * 4` values laid out as
|
||||
/// `[upper, middle, lower, stddev]` per input row, warmup rows all `NaN`.
|
||||
///
|
||||
/// For a fresh, all-finite slice it inlines `update`'s rolling `sum`/`sum_sq`
|
||||
/// and drift-reseed, writing the four band values directly instead of an
|
||||
/// `Option<BollingerOutput>` per element. Same add/subtract order, same reseed
|
||||
/// cadence, same variance/`sqrt` math — so it is *bit-for-bit* equal to
|
||||
/// replaying `update`, including the long-stream drift bound. Any other state,
|
||||
/// or a non-finite element, defers to the exact `update` replay.
|
||||
///
|
||||
/// This is a *separate* entry point from the trait [`batch`](crate::BatchExt::batch),
|
||||
/// which returns `Vec<Option<BollingerOutput>>`; only the bindings, which want
|
||||
/// a flat `f64` buffer, call this.
|
||||
pub fn batch_bands(&mut self, inputs: &[f64]) -> Vec<f64> {
|
||||
let p = self.period;
|
||||
let n = inputs.len();
|
||||
if self.count != 0
|
||||
|| self.updates_since_recompute != 0
|
||||
|| !inputs.iter().all(|x| x.is_finite())
|
||||
{
|
||||
// Slow path: exact replay of `update` into the flat layout.
|
||||
let mut out = vec![f64::NAN; n * 4];
|
||||
for (i, &x) in inputs.iter().enumerate() {
|
||||
if let Some(o) = self.update(x) {
|
||||
out[i * 4] = o.upper;
|
||||
out[i * 4 + 1] = o.middle;
|
||||
out[i * 4 + 2] = o.lower;
|
||||
out[i * 4 + 3] = o.stddev;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let p_f64 = p as f64;
|
||||
let mult = self.multiplier;
|
||||
// Pre-sized output: warmup rows stay NaN, ready rows are written in place
|
||||
// by index — no per-row `push` length/capacity check.
|
||||
let mut out = vec![f64::NAN; n * 4];
|
||||
for (i, &x) in inputs.iter().enumerate() {
|
||||
if self.count == p {
|
||||
let old = self.buf[self.head];
|
||||
self.sum -= old;
|
||||
self.sum_sq -= old * old;
|
||||
self.buf[self.head] = x;
|
||||
self.sum += x;
|
||||
self.sum_sq += x * x;
|
||||
} else {
|
||||
self.buf[self.head] = x;
|
||||
self.sum += x;
|
||||
self.sum_sq += x * x;
|
||||
self.count += 1;
|
||||
}
|
||||
self.head += 1;
|
||||
if self.head == p {
|
||||
self.head = 0;
|
||||
}
|
||||
self.updates_since_recompute += 1;
|
||||
if self.updates_since_recompute >= RECOMPUTE_EVERY * p {
|
||||
let chronological = self.buf[self.head..].iter().chain(&self.buf[..self.head]);
|
||||
self.sum = chronological.clone().copied().sum();
|
||||
self.sum_sq = chronological.map(|&v| v * v).sum();
|
||||
self.updates_since_recompute = 0;
|
||||
}
|
||||
if self.count == p {
|
||||
let mean = self.sum / p_f64;
|
||||
let stddev = (self.sum_sq / p_f64 - mean * mean).max(0.0).sqrt();
|
||||
let band = mult * stddev;
|
||||
out[i * 4] = mean + band;
|
||||
out[i * 4 + 1] = mean;
|
||||
out[i * 4 + 2] = mean - band;
|
||||
out[i * 4 + 3] = stddev;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn current(&self) -> Option<BollingerOutput> {
|
||||
if self.count != self.period {
|
||||
return None;
|
||||
@@ -352,6 +428,79 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter()
|
||||
.zip(b)
|
||||
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
||||
}
|
||||
|
||||
/// Flat `n*4` `[upper, middle, lower, stddev]` replay of `update`.
|
||||
fn bb_replay(period: usize, mult: f64, series: &[f64]) -> Vec<f64> {
|
||||
let mut bb = BollingerBands::new(period, mult).unwrap();
|
||||
let mut out = Vec::with_capacity(series.len() * 4);
|
||||
for &x in series {
|
||||
match bb.update(x) {
|
||||
Some(o) => out.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
|
||||
None => out.extend_from_slice(&[f64::NAN; 4]),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_bands_fast_path_is_bit_identical_with_reseed() {
|
||||
// > 16*period inputs so the drift-reseed branch fires inside batch_bands.
|
||||
let series: Vec<f64> = (0..500)
|
||||
.map(|i| (f64::from(i) * 0.2).sin() * 10.0 + 50.0)
|
||||
.collect();
|
||||
let mut bb = BollingerBands::new(20, 2.0).unwrap();
|
||||
let got = bb.batch_bands(&series);
|
||||
assert!(bits_eq(&got, &bb_replay(20, 2.0, &series)));
|
||||
// State continues identically.
|
||||
let mut ref_bb = BollingerBands::new(20, 2.0).unwrap();
|
||||
for &x in &series {
|
||||
ref_bb.update(x);
|
||||
}
|
||||
assert_eq!(bb.update(55.0), ref_bb.update(55.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_bands_falls_back_on_non_finite() {
|
||||
let series = [1.0, 2.0, 3.0, f64::NAN, 5.0, 6.0, 7.0];
|
||||
let mut bb = BollingerBands::new(3, 2.0).unwrap();
|
||||
assert!(bits_eq(
|
||||
&bb.batch_bands(&series),
|
||||
&bb_replay(3, 2.0, &series)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_bands_falls_back_when_not_fresh() {
|
||||
let mut bb = BollingerBands::new(3, 2.0).unwrap();
|
||||
bb.update(99.0);
|
||||
let series = [1.0, 2.0, 3.0, 4.0];
|
||||
let mut ref_bb = BollingerBands::new(3, 2.0).unwrap();
|
||||
ref_bb.update(99.0);
|
||||
let mut want = Vec::new();
|
||||
for &x in &series {
|
||||
match ref_bb.update(x) {
|
||||
Some(o) => want.extend_from_slice(&[o.upper, o.middle, o.lower, o.stddev]),
|
||||
None => want.extend_from_slice(&[f64::NAN; 4]),
|
||||
}
|
||||
}
|
||||
assert!(bits_eq(&bb.batch_bands(&series), &want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_bands_sub_period_slice_is_all_nan() {
|
||||
let series = [1.0, 2.0, 3.0];
|
||||
let mut bb = BollingerBands::new(10, 2.0).unwrap();
|
||||
let got = bb.batch_bands(&series);
|
||||
assert!(bits_eq(&got, &bb_replay(10, 2.0, &series)));
|
||||
assert!(got.iter().all(|x| x.is_nan()) && got.len() == 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut bb = BollingerBands::new(5, 2.0).unwrap();
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Burke Ratio — mean return over the square root of the summed squared drawdowns.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Burke Ratio over a trailing window of `period` returns.
|
||||
///
|
||||
/// ```text
|
||||
/// equity_t = Π_{i<=t} (1 + return_i) (compounded curve)
|
||||
/// peak_t = max_{s<=t} equity_s
|
||||
/// dd_t = (peak_t − equity_t) / peak_t (fractional drawdown, >= 0)
|
||||
/// Burke = mean(returns) / sqrt( Σ dd_t² )
|
||||
/// ```
|
||||
///
|
||||
/// The Burke Ratio divides the average per-period return by the **Euclidean norm of
|
||||
/// the drawdowns** — the square root of the *sum* of squared drawdowns. Squaring
|
||||
/// penalises deep drawdowns far more than shallow ones, and summing (rather than
|
||||
/// averaging) means the denominator grows with both the depth and the *number* of
|
||||
/// drawdowns. This makes Burke the most outlier-sensitive of Wickra's three
|
||||
/// drawdown ratios: where the [`SterlingRatio`](crate::SterlingRatio) averages raw
|
||||
/// drawdowns and shrugs off a single crater, Burke makes that crater dominate.
|
||||
/// The [`MartinRatio`](crate::MartinRatio) sits between them with a root-*mean*
|
||||
/// square of percentage drawdowns. A window that never draws down has a zero
|
||||
/// denominator and the indicator reports `0.0`.
|
||||
///
|
||||
/// The first value lands after `period` returns; each `update` rebuilds the equity
|
||||
/// curve over the window (O(period)), which is O(1) in the length of the overall
|
||||
/// series.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BurkeRatio};
|
||||
///
|
||||
/// let mut indicator = BurkeRatio::new(12).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..24 {
|
||||
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BurkeRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl BurkeRatio {
|
||||
/// Construct a Burke Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "burke ratio needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let length = self.window.len() as f64;
|
||||
let mut sum_return = 0.0;
|
||||
let mut sum_drawdown_sq = 0.0;
|
||||
let mut equity = 1.0;
|
||||
let mut peak: f64 = 1.0;
|
||||
for ret in &self.window {
|
||||
sum_return += *ret;
|
||||
equity *= 1.0 + *ret;
|
||||
peak = peak.max(equity);
|
||||
let drawdown = (peak - equity) / peak;
|
||||
sum_drawdown_sq += drawdown * drawdown;
|
||||
}
|
||||
let denom = sum_drawdown_sq.sqrt();
|
||||
if denom > 0.0 {
|
||||
(sum_return / length) / denom
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BurkeRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BurkeRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
BurkeRatio::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let br = BurkeRatio::new(12).unwrap();
|
||||
assert_eq!(br.period(), 12);
|
||||
assert_eq!(br.warmup_period(), 12);
|
||||
assert_eq!(br.name(), "BurkeRatio");
|
||||
assert!(!br.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns [0.1, -0.1, 0.1]: dd = [0, 0.1, 0.01].
|
||||
// Σ dd² = 0.01 + 0.0001 = 0.0101; denom = sqrt(0.0101).
|
||||
// Burke = (0.1/3) / sqrt(0.0101).
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
let out = br.batch(&[0.1, -0.1, 0.1]);
|
||||
let expected = (0.1_f64 / 3.0) / (0.0101_f64).sqrt();
|
||||
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drawdown_is_zero() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
let last = br
|
||||
.batch(&[0.01, 0.02, 0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_window_is_negative() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
let last = br
|
||||
.batch(&[-0.05, -0.02, -0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
assert_eq!(br.update(0.1), None);
|
||||
assert_eq!(br.update(f64::NAN), None);
|
||||
assert_eq!(br.update(-0.1), None);
|
||||
assert!(br.update(0.1).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
br.batch(&[0.1, -0.1, 0.1]);
|
||||
assert!(br.is_ready());
|
||||
br.reset();
|
||||
assert!(!br.is_ready());
|
||||
assert_eq!(br.update(0.1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 0.05)
|
||||
.collect();
|
||||
let batch = BurkeRatio::new(12).unwrap().batch(&rets);
|
||||
let mut streamer = BurkeRatio::new(12).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
//! CandleVolume — candlestick body with a volume-scaled width.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`CandleVolume`]: the signed candle body and its volume-relative width.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CandleVolumeOutput {
|
||||
/// Signed body `close − open` (positive = bullish candle).
|
||||
pub body: f64,
|
||||
/// Box width — volume relative to its `period` average (`1.0` = average).
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// CandleVolume — the candlestick analogue of [`Equivolume`](crate::Equivolume):
|
||||
/// each bar's **body** (`close − open`) paired with a **width** proportional to its
|
||||
/// volume relative to the recent average.
|
||||
///
|
||||
/// ```text
|
||||
/// body = close − open (signed; + bullish, − bearish)
|
||||
/// width = volume / SMA(volume, period) (1.0 = average volume)
|
||||
/// ```
|
||||
///
|
||||
/// Where Equivolume uses the high-low *range* for the box height, CandleVolume uses
|
||||
/// the candlestick *body*, preserving direction: a wide bullish body (long up
|
||||
/// candle on heavy volume) is strong demand, a wide bearish body strong supply, and
|
||||
/// a narrow body on heavy volume (wide but short) is churn. The signed body plus
|
||||
/// the normalised width capture both the move's direction and the participation
|
||||
/// behind it.
|
||||
///
|
||||
/// The first value lands after `period` inputs (to seed the volume average). Each
|
||||
/// `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, CandleVolume};
|
||||
///
|
||||
/// let mut indicator = CandleVolume::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0 + f64::from(i), 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CandleVolume {
|
||||
period: usize,
|
||||
vol_sma: Sma,
|
||||
last: Option<CandleVolumeOutput>,
|
||||
}
|
||||
|
||||
impl CandleVolume {
|
||||
/// Construct a CandleVolume with the given volume-averaging `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
vol_sma: Sma::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured volume-averaging period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<CandleVolumeOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CandleVolume {
|
||||
type Input = Candle;
|
||||
type Output = CandleVolumeOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<CandleVolumeOutput> {
|
||||
let avg_vol = self.vol_sma.update(candle.volume)?;
|
||||
let body = candle.close - candle.open;
|
||||
let width = if avg_vol > 0.0 {
|
||||
candle.volume / avg_vol
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let out = CandleVolumeOutput { body, width };
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.vol_sma.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CandleVolume"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(open: f64, close: f64, volume: f64) -> Candle {
|
||||
let high = open.max(close) + 1.0;
|
||||
let low = open.min(close) - 1.0;
|
||||
Candle::new_unchecked(open, high, low, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(CandleVolume::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cv = CandleVolume::new(14).unwrap();
|
||||
assert_eq!(cv.period(), 14);
|
||||
assert_eq!(cv.warmup_period(), 14);
|
||||
assert_eq!(cv.name(), "CandleVolume");
|
||||
assert!(!cv.is_ready());
|
||||
assert_eq!(cv.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut cv = CandleVolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(100.0, 101.0, 1_000.0)).collect();
|
||||
let out = cv.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_body_positive() {
|
||||
let mut cv = CandleVolume::new(2).unwrap();
|
||||
let out = cv
|
||||
.batch(&[c(100.0, 103.0, 1_000.0), c(100.0, 103.0, 1_000.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.body, 3.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_body_negative() {
|
||||
let mut cv = CandleVolume::new(2).unwrap();
|
||||
let out = cv
|
||||
.batch(&[c(103.0, 100.0, 1_000.0), c(103.0, 100.0, 1_000.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.body, -3.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavy_bar_is_wide() {
|
||||
let mut cv = CandleVolume::new(3).unwrap();
|
||||
let candles = [
|
||||
c(100.0, 101.0, 1_000.0),
|
||||
c(100.0, 101.0, 1_000.0),
|
||||
c(100.0, 101.0, 4_000.0),
|
||||
];
|
||||
let out = cv.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(out.width > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cv = CandleVolume::new(3).unwrap();
|
||||
cv.batch(&[c(100.0, 101.0, 1_000.0); 6]);
|
||||
assert!(cv.is_ready());
|
||||
cv.reset();
|
||||
assert!(!cv.is_ready());
|
||||
assert_eq!(cv.value(), None);
|
||||
assert_eq!(cv.update(c(100.0, 101.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_gives_zero_width() {
|
||||
let mut cv = CandleVolume::new(2).unwrap();
|
||||
let out = cv
|
||||
.batch(&[c(10.0, 11.0, 0.0), c(11.0, 12.0, 0.0), c(12.0, 13.0, 0.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(out.width, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.25).sin() * 5.0;
|
||||
c(b, b + 0.5, 1_000.0 + f64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let batch = CandleVolume::new(14).unwrap().batch(&candles);
|
||||
let mut b = CandleVolume::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Central Pivot Range (CPR) — the pivot plus its two central levels.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`CentralPivotRange`]: the pivot and the two central lines that
|
||||
/// bracket it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CentralPivotRangeOutput {
|
||||
/// Pivot point `(high + low + close) / 3`.
|
||||
pub pivot: f64,
|
||||
/// Top central line — the higher of the two central levels.
|
||||
pub tc: f64,
|
||||
/// Bottom central line — the lower of the two central levels.
|
||||
pub bc: f64,
|
||||
}
|
||||
|
||||
/// Central Pivot Range (CPR) — the classic pivot point flanked by two "central"
|
||||
/// levels whose separation gauges the day's expected character.
|
||||
///
|
||||
/// ```text
|
||||
/// pivot = (high + low + close) / 3
|
||||
/// bc' = (high + low) / 2
|
||||
/// tc' = 2·pivot − bc'
|
||||
/// TC = max(tc', bc'), BC = min(tc', bc')
|
||||
/// ```
|
||||
///
|
||||
/// The CPR is computed from the **previous** period's bar (feed it completed
|
||||
/// daily/weekly bars). The width of the range `TC − BC` is the headline read: a
|
||||
/// **narrow** CPR signals a likely trending day (price has little balance area to
|
||||
/// chew through), while a **wide** CPR signals a likely range-bound, balanced
|
||||
/// day. Price opening above the whole range is bullish, below it bearish, inside
|
||||
/// it neutral. The `tc'`/`bc'` formulas are symmetric about the pivot; this
|
||||
/// implementation labels the larger as `TC` and the smaller as `BC` so `TC >= BC`
|
||||
/// always holds.
|
||||
///
|
||||
/// There are no parameters and no warmup — each completed bar yields one CPR.
|
||||
/// Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, CentralPivotRange};
|
||||
///
|
||||
/// let mut indicator = CentralPivotRange::new();
|
||||
/// let prev_day = Candle::new(101.0, 110.0, 90.0, 105.0, 1_000.0, 0).unwrap();
|
||||
/// let cpr = indicator.update(prev_day).unwrap();
|
||||
/// assert!(cpr.tc >= cpr.bc);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CentralPivotRange {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl CentralPivotRange {
|
||||
/// Construct a new Central Pivot Range. The indicator is parameter-free.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { ready: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CentralPivotRange {
|
||||
type Input = Candle;
|
||||
type Output = CentralPivotRangeOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<CentralPivotRangeOutput> {
|
||||
let pivot = (candle.high + candle.low + candle.close) / 3.0;
|
||||
let bc_raw = f64::midpoint(candle.high, candle.low);
|
||||
let tc_raw = 2.0 * pivot - bc_raw;
|
||||
let tc = tc_raw.max(bc_raw);
|
||||
let bc = tc_raw.min(bc_raw);
|
||||
self.ready = true;
|
||||
Some(CentralPivotRangeOutput { pivot, tc, bc })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CentralPivotRange"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cpr = CentralPivotRange::new();
|
||||
assert_eq!(cpr.warmup_period(), 1);
|
||||
assert_eq!(cpr.name(), "CentralPivotRange");
|
||||
assert!(!cpr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formula_reference_values() {
|
||||
// H=110, L=90, C=105 -> pivot = 305/3; bc' = 100; tc' = 2*pivot - 100.
|
||||
let out = CentralPivotRange::new()
|
||||
.update(c(110.0, 90.0, 105.0))
|
||||
.unwrap();
|
||||
let pivot = 305.0 / 3.0;
|
||||
let bc_raw = 100.0;
|
||||
let tc_raw = 2.0 * pivot - bc_raw;
|
||||
assert!((out.pivot - pivot).abs() < 1e-12);
|
||||
assert!((out.tc - tc_raw.max(bc_raw)).abs() < 1e-12);
|
||||
assert!((out.bc - tc_raw.min(bc_raw)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_never_below_bc() {
|
||||
let out = CentralPivotRange::new()
|
||||
.update(c(200.0, 100.0, 150.0))
|
||||
.unwrap();
|
||||
assert!(out.tc >= out.bc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_bar_collapses_range() {
|
||||
// H = L = C -> pivot = bc' = tc' = the price; range collapses.
|
||||
let out = CentralPivotRange::new()
|
||||
.update(c(50.0, 50.0, 50.0))
|
||||
.unwrap();
|
||||
assert_eq!(out.pivot, 50.0);
|
||||
assert_eq!(out.tc, 50.0);
|
||||
assert_eq!(out.bc, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_after_first_update() {
|
||||
let mut cpr = CentralPivotRange::new();
|
||||
assert!(!cpr.is_ready());
|
||||
cpr.update(c(11.0, 9.0, 10.0));
|
||||
assert!(cpr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cpr = CentralPivotRange::new();
|
||||
cpr.update(c(11.0, 9.0, 10.0));
|
||||
assert!(cpr.is_ready());
|
||||
cpr.reset();
|
||||
assert!(!cpr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| c(f64::from(i) + 2.0, f64::from(i), f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let batch = CentralPivotRange::new().batch(&candles);
|
||||
let mut b = CentralPivotRange::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Common Sense Ratio (Schwager / Carver) — profit factor multiplied by the tail ratio.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Common Sense Ratio over a trailing window of `period` returns.
|
||||
///
|
||||
/// ```text
|
||||
/// ProfitFactor = Σ gains / Σ |losses| over the window
|
||||
/// TailRatio = P95(returns) / |P5(returns)| over the window
|
||||
/// CSR = ProfitFactor · TailRatio
|
||||
/// ```
|
||||
///
|
||||
/// The Common Sense Ratio fuses two views of a return series into one number. The
|
||||
/// [profit factor](crate::ProfitFactor) captures the *body* of the distribution —
|
||||
/// how much you make per unit you lose on the average bar. The
|
||||
/// [`TailRatio`](crate::TailRatio) captures the *extremes* — whether the largest
|
||||
/// gains outweigh the largest losses. Multiplying them produces a ratio that is
|
||||
/// only comfortably above `1.0` when a strategy wins on both fronts: a respectable
|
||||
/// profit factor can still hide catastrophic left-tail risk, and a fat right tail
|
||||
/// means little if the body bleeds. Above `1.0` the strategy is sound on a
|
||||
/// common-sense basis; below `1.0` something — body or tail — is working against it.
|
||||
///
|
||||
/// Percentiles use linear interpolation over the sorted window. A window with no
|
||||
/// losses (zero profit-factor denominator) or no left tail (zero P5) reports `0.0`
|
||||
/// rather than dividing by zero.
|
||||
///
|
||||
/// The first value lands after `period` returns; each `update` re-sorts the window
|
||||
/// (O(period log period)), which is O(1) in the length of the overall series.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, CommonSenseRatio};
|
||||
///
|
||||
/// let mut indicator = CommonSenseRatio::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update((f64::from(i) * 0.3).sin() * 0.02);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommonSenseRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl CommonSenseRatio {
|
||||
/// Construct a Common Sense Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (percentiles need at least
|
||||
/// two observations).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "common sense ratio needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let mut gains = 0.0;
|
||||
let mut losses = 0.0;
|
||||
for ret in &self.window {
|
||||
gains += ret.max(0.0);
|
||||
losses += (-ret).max(0.0);
|
||||
}
|
||||
if losses <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
|
||||
sorted.sort_unstable_by(f64::total_cmp);
|
||||
let lower_tail = percentile(&sorted, 5.0).abs();
|
||||
if lower_tail <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let profit_factor = gains / losses;
|
||||
let tail_ratio = percentile(&sorted, 95.0) / lower_tail;
|
||||
profit_factor * tail_ratio
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear-interpolation percentile of an ascending, non-empty slice.
|
||||
fn percentile(sorted: &[f64], pct: f64) -> f64 {
|
||||
let last_index = sorted.len() - 1;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let rank = pct / 100.0 * last_index as f64;
|
||||
let floor = rank.floor();
|
||||
// `rank` lies in `[0, last_index]`, so its floor is a valid in-bounds index.
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let lower = floor as usize;
|
||||
if lower >= last_index {
|
||||
return sorted[last_index];
|
||||
}
|
||||
let frac = rank - floor;
|
||||
sorted[lower] + frac * (sorted[lower + 1] - sorted[lower])
|
||||
}
|
||||
|
||||
impl Indicator for CommonSenseRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CommonSenseRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
CommonSenseRatio::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let csr = CommonSenseRatio::new(20).unwrap();
|
||||
assert_eq!(csr.period(), 20);
|
||||
assert_eq!(csr.warmup_period(), 20);
|
||||
assert_eq!(csr.name(), "CommonSenseRatio");
|
||||
assert!(!csr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// window [-0.04, -0.02, 0.0, 0.02, 0.04].
|
||||
// gains = 0.06, losses = 0.06 -> profit factor 1.0.
|
||||
// P95 = 0.036, |P5| = 0.036 -> tail ratio 1.0. CSR = 1.0.
|
||||
let mut csr = CommonSenseRatio::new(5).unwrap();
|
||||
let out = csr.batch(&[-0.04, -0.02, 0.0, 0.02, 0.04]);
|
||||
assert_relative_eq!(out[4].unwrap(), 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_losses_is_zero() {
|
||||
let mut csr = CommonSenseRatio::new(3).unwrap();
|
||||
let last = csr
|
||||
.batch(&[0.01, 0.02, 0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_zero() {
|
||||
// All zeros: no losses denominator -> zero (the gains/losses guard fires).
|
||||
let mut csr = CommonSenseRatio::new(4).unwrap();
|
||||
let last = csr.batch(&[0.0; 4]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut csr = CommonSenseRatio::new(3).unwrap();
|
||||
assert_eq!(csr.update(0.01), None);
|
||||
assert_eq!(csr.update(f64::NAN), None);
|
||||
assert_eq!(csr.update(-0.02), None);
|
||||
assert!(csr.update(0.03).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut csr = CommonSenseRatio::new(3).unwrap();
|
||||
csr.batch(&[-0.01, 0.0, 0.02]);
|
||||
assert!(csr.is_ready());
|
||||
csr.reset();
|
||||
assert!(!csr.is_ready());
|
||||
assert_eq!(csr.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 0.02)
|
||||
.collect();
|
||||
let batch = CommonSenseRatio::new(15).unwrap().batch(&rets);
|
||||
let mut streamer = CommonSenseRatio::new(15).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentile_at_top_returns_last() {
|
||||
// The rank floor reaching the final index returns the largest element.
|
||||
assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_lower_tail_is_zero() {
|
||||
// One loss but a 5th percentile of exactly zero: the tail term collapses
|
||||
// and the indicator reports 0.0 rather than dividing by zero. With period
|
||||
// 21 the 5% rank lands on sorted index 1, which is 0.0 here.
|
||||
let mut returns = vec![0.0; 21];
|
||||
returns[0] = -0.1;
|
||||
let mut csr = CommonSenseRatio::new(21).unwrap();
|
||||
let last = csr.batch(&returns).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Composite Profile — POC and value area over a long composite window.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`CompositeProfile`]: the point of control and the value-area bounds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CompositeProfileOutput {
|
||||
/// Point of Control — the price (bin centre) with the most volume.
|
||||
pub poc: f64,
|
||||
/// Value-Area High — top of the band holding `value_area_pct` of volume.
|
||||
pub vah: f64,
|
||||
/// Value-Area Low — bottom of that band.
|
||||
pub val: f64,
|
||||
}
|
||||
|
||||
/// Composite Profile — a multi-session volume profile reduced to its **point of
|
||||
/// control** and **value area**, built over a long composite window.
|
||||
///
|
||||
/// ```text
|
||||
/// build a `bins`-bucket volume profile over the last `period` candles
|
||||
/// POC = bin with the most volume
|
||||
/// expand from the POC, always adding the heavier adjacent bin, until the
|
||||
/// accumulated volume reaches `value_area_pct` of the total
|
||||
/// VAH / VAL = the highest / lowest price included
|
||||
/// ```
|
||||
///
|
||||
/// A composite profile merges many sessions into one structure to reveal the
|
||||
/// dominant value area and control price across a longer horizon — the levels that
|
||||
/// matter for swing positioning rather than a single day. The point of control is
|
||||
/// the fairest price (heaviest trade); the value area (classically 70% of volume)
|
||||
/// brackets where the market spent most of its time. Price inside the value area is
|
||||
/// "in balance"; acceptance outside it signals a value migration.
|
||||
///
|
||||
/// The first value lands after `period` candles; each `update` rebuilds the
|
||||
/// profile in O(`period · bins`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, CompositeProfile};
|
||||
///
|
||||
/// let mut indicator = CompositeProfile::new(100, 50, 0.70).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..150 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.1).sin() * 8.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompositeProfile {
|
||||
period: usize,
|
||||
bins: usize,
|
||||
value_area_pct: f64,
|
||||
window: VecDeque<Candle>,
|
||||
last: Option<CompositeProfileOutput>,
|
||||
}
|
||||
|
||||
impl CompositeProfile {
|
||||
/// Construct a Composite Profile.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period` or `bins` is zero, or
|
||||
/// [`Error::InvalidParameter`] if `value_area_pct` is not in `(0, 1]`.
|
||||
pub fn new(period: usize, bins: usize, value_area_pct: f64) -> Result<Self> {
|
||||
if period == 0 || bins == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !value_area_pct.is_finite() || value_area_pct <= 0.0 || value_area_pct > 1.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "value_area_pct must be in (0, 1]",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
bins,
|
||||
value_area_pct,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bins, value_area_pct)`.
|
||||
pub const fn params(&self) -> (usize, usize, f64) {
|
||||
(self.period, self.bins, self.value_area_pct)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<CompositeProfileOutput> {
|
||||
self.last
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
fn compute(&self) -> CompositeProfileOutput {
|
||||
let mut low = f64::INFINITY;
|
||||
let mut high = f64::NEG_INFINITY;
|
||||
for c in &self.window {
|
||||
low = low.min(c.low);
|
||||
high = high.max(c.high);
|
||||
}
|
||||
let span = high - low;
|
||||
if span <= 0.0 {
|
||||
return CompositeProfileOutput {
|
||||
poc: low,
|
||||
vah: low,
|
||||
val: low,
|
||||
};
|
||||
}
|
||||
let width = span / self.bins as f64;
|
||||
let centre = |idx: usize| low + (idx as f64 + 0.5) * width;
|
||||
let mut hist = vec![0.0; self.bins];
|
||||
for c in &self.window {
|
||||
if c.volume == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let share = c.volume / (hi_idx - lo_idx + 1) as f64;
|
||||
for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
|
||||
*bin += share;
|
||||
}
|
||||
}
|
||||
let total: f64 = hist.iter().sum();
|
||||
let mut poc = 0;
|
||||
let mut poc_vol = f64::NEG_INFINITY;
|
||||
for (idx, &vol) in hist.iter().enumerate() {
|
||||
if vol > poc_vol {
|
||||
poc_vol = vol;
|
||||
poc = idx;
|
||||
}
|
||||
}
|
||||
let target = total * self.value_area_pct;
|
||||
let mut acc = hist[poc];
|
||||
let mut top = poc;
|
||||
let mut bottom = poc;
|
||||
while acc < target && (top < self.bins - 1 || bottom > 0) {
|
||||
let above = if top < self.bins - 1 {
|
||||
hist[top + 1]
|
||||
} else {
|
||||
f64::NEG_INFINITY
|
||||
};
|
||||
let below = if bottom > 0 {
|
||||
hist[bottom - 1]
|
||||
} else {
|
||||
f64::NEG_INFINITY
|
||||
};
|
||||
if above >= below {
|
||||
top += 1;
|
||||
acc += hist[top];
|
||||
} else {
|
||||
bottom -= 1;
|
||||
acc += hist[bottom];
|
||||
}
|
||||
}
|
||||
CompositeProfileOutput {
|
||||
poc: centre(poc),
|
||||
vah: centre(top),
|
||||
val: centre(bottom),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CompositeProfile {
|
||||
type Input = Candle;
|
||||
type Output = CompositeProfileOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<CompositeProfileOutput> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(candle);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CompositeProfile"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
f64::midpoint(high, low),
|
||||
high,
|
||||
low,
|
||||
f64::midpoint(high, low),
|
||||
volume,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
CompositeProfile::new(0, 50, 0.7),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
CompositeProfile::new(100, 0, 0.7),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
CompositeProfile::new(100, 50, 0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
CompositeProfile::new(100, 50, 1.5),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = CompositeProfile::new(100, 50, 0.7).unwrap();
|
||||
assert_eq!(p.params(), (100, 50, 0.7));
|
||||
assert_eq!(p.warmup_period(), 100);
|
||||
assert_eq!(p.name(), "CompositeProfile");
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut p = CompositeProfile::new(4, 8, 0.7).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
|
||||
let out = p.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_area_brackets_poc() {
|
||||
let mut p = CompositeProfile::new(20, 30, 0.7).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.3).sin() * 8.0,
|
||||
90.0 + (f64::from(i) * 0.3).cos() * 8.0,
|
||||
1_000.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for o in p.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.val <= o.poc && o.poc <= o.vah);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poc_at_heavy_cluster() {
|
||||
// Volume clustered at ~100; thin pokes elsewhere -> POC near 100.
|
||||
let mut p = CompositeProfile::new(6, 30, 0.7).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
|
||||
candles.push(c(140.0, 60.0, 50.0));
|
||||
let out = p.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
(out.poc - 100.0).abs() < 5.0,
|
||||
"POC should sit at the cluster, got {}",
|
||||
out.poc
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = CompositeProfile::new(4, 8, 0.7).unwrap();
|
||||
p.batch(&[c(110.0, 90.0, 1_000.0); 6]);
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.25).sin() * 9.0,
|
||||
90.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = CompositeProfile::new(50, 50, 0.7).unwrap().batch(&candles);
|
||||
let mut b = CompositeProfile::new(50, 50, 0.7).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_collapses_to_price() {
|
||||
// Zero high-low span returns the price for POC, VAH and VAL.
|
||||
let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap();
|
||||
cp.update(c(50.0, 50.0, 10.0));
|
||||
let out = cp.update(c(50.0, 50.0, 10.0)).unwrap();
|
||||
assert_eq!(out.poc, out.vah);
|
||||
assert_eq!(out.poc, out.val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_window_is_handled() {
|
||||
// Non-flat window of zero-volume candles hits the skip path.
|
||||
let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap();
|
||||
cp.update(c(60.0, 40.0, 0.0));
|
||||
assert!(cp.update(c(60.0, 40.0, 0.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_area_expands_down_from_top_poc() {
|
||||
// POC sits in the top bin; with a wide value-area target the area runs
|
||||
// out of bins above (the ceiling branch) and keeps expanding downward.
|
||||
let mut cp = CompositeProfile::new(2, 3, 0.9).unwrap();
|
||||
cp.update(c(100.0, 0.0, 30.0)); // thin spread across all three bins
|
||||
let out = cp.update(c(100.0, 67.0, 60.0)).unwrap(); // heavy in the top bin
|
||||
assert!(out.val <= out.poc && out.poc <= out.vah);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Ehlers Correlation Trend Indicator (CTI) — Pearson correlation of price vs. time.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Correlation Trend Indicator** (CTI) — the Pearson correlation
|
||||
/// coefficient between price and a perfectly straight ramp over the lookback.
|
||||
///
|
||||
/// ```text
|
||||
/// CTI = corr( price over the window , [0, 1, …, period−1] )
|
||||
/// ```
|
||||
///
|
||||
/// John Ehlers' CTI asks "how closely does recent price track a straight line?"
|
||||
/// by correlating the windowed price against the time index itself. A reading near
|
||||
/// `+1` means price is rising in a near-perfect line (strong uptrend); near `−1`
|
||||
/// means a clean downtrend; near `0` means no linear trend (a range or choppy
|
||||
/// market). Because correlation is scale- and offset-invariant, the slope's
|
||||
/// steepness does not matter — only how *linear* the move is — which makes CTI an
|
||||
/// unusually clean trend/range classifier. It differs from
|
||||
/// [`Autocorrelation`](crate::Autocorrelation), which correlates price with a
|
||||
/// *lagged copy of itself* rather than with time.
|
||||
///
|
||||
/// The output is in `[−1, +1]`; a flat window (zero price variance) returns `0`.
|
||||
/// The first value lands after `period` inputs; each `update` recomputes the
|
||||
/// correlation over the window in O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, CorrelationTrendIndicator};
|
||||
///
|
||||
/// let mut indicator = CorrelationTrendIndicator::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i)); // a clean uptrend
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CorrelationTrendIndicator {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl CorrelationTrendIndicator {
|
||||
/// Construct a CTI over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs two
|
||||
/// points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "CTI needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured lookback period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let n = self.period as f64;
|
||||
let mut sum_x = 0.0;
|
||||
let mut sum_xx = 0.0;
|
||||
let mut sum_xt = 0.0;
|
||||
for (i, &x) in self.window.iter().enumerate() {
|
||||
let t = i as f64;
|
||||
sum_x += x;
|
||||
sum_xx += x * x;
|
||||
sum_xt += x * t;
|
||||
}
|
||||
// Time index 0..n-1 has closed-form sums.
|
||||
let sum_t = n * (n - 1.0) / 2.0;
|
||||
let sum_tt = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
|
||||
let cov = n * sum_xt - sum_x * sum_t;
|
||||
let var_x = n * sum_xx - sum_x * sum_x;
|
||||
let var_t = n * sum_tt - sum_t * sum_t;
|
||||
let denom = (var_x * var_t).sqrt();
|
||||
if denom == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(cov / denom).clamp(-1.0, 1.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CorrelationTrendIndicator {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CorrelationTrendIndicator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
CorrelationTrendIndicator::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(CorrelationTrendIndicator::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cti = CorrelationTrendIndicator::new(20).unwrap();
|
||||
assert_eq!(cti.period(), 20);
|
||||
assert_eq!(cti.warmup_period(), 20);
|
||||
assert_eq!(cti.name(), "CorrelationTrendIndicator");
|
||||
assert!(!cti.is_ready());
|
||||
assert_eq!(cti.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
|
||||
let out = cti.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_uptrend_is_one() {
|
||||
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
|
||||
let last = cti
|
||||
.batch(&(0..40).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_downtrend_is_minus_one() {
|
||||
let mut cti = CorrelationTrendIndicator::new(10).unwrap();
|
||||
let last = cti
|
||||
.batch(&(0..40).map(|i| 100.0 - f64::from(i)).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_zero() {
|
||||
let mut cti = CorrelationTrendIndicator::new(8).unwrap();
|
||||
let last = cti.batch(&[7.0; 16]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut cti = CorrelationTrendIndicator::new(20).unwrap();
|
||||
for v in cti
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((-1.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
|
||||
let ready = cti
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(cti.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cti = CorrelationTrendIndicator::new(4).unwrap();
|
||||
cti.batch(&[1.0, 2.0, 3.0, 4.0]);
|
||||
assert!(cti.is_ready());
|
||||
cti.reset();
|
||||
assert!(!cti.is_ready());
|
||||
assert_eq!(cti.value(), None);
|
||||
assert_eq!(cti.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = CorrelationTrendIndicator::new(20).unwrap().batch(&xs);
|
||||
let mut b = CorrelationTrendIndicator::new(20).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Dollar bar builder — close a bar each time accumulated traded value reaches a threshold.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::BarBuilder;
|
||||
|
||||
/// One completed dollar bar (an OHLC aggregate spanning ~`dollar_per_bar` of traded value).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct DollarBar {
|
||||
/// Open of the first candle in the bar.
|
||||
pub open: f64,
|
||||
/// Highest high across the bar.
|
||||
pub high: f64,
|
||||
/// Lowest low across the bar.
|
||||
pub low: f64,
|
||||
/// Close of the candle that closed the bar.
|
||||
pub close: f64,
|
||||
/// Summed volume across the bar.
|
||||
pub volume: f64,
|
||||
/// Accumulated traded value (`Σ close · volume`, `>= dollar_per_bar`).
|
||||
pub dollar: f64,
|
||||
}
|
||||
|
||||
/// Dollar bar builder — emits a bar each time accumulated traded value
|
||||
/// (`price × volume`) reaches `dollar_per_bar`.
|
||||
///
|
||||
/// Dollar bars are the most drift-robust of the information-driven bar types. Where
|
||||
/// [`VolumeBars`](crate::VolumeBars) close on a fixed *quantity* of shares/contracts,
|
||||
/// dollar bars close on a fixed *value*: each candle contributes `close × volume` to
|
||||
/// the running total. As a market's price level rises over years, a fixed share
|
||||
/// count buys ever more value and volume bars drift in meaning; dollar bars stay
|
||||
/// economically comparable across the whole history, which is why they are the
|
||||
/// preferred sampling for long backtests and machine-learning features.
|
||||
///
|
||||
/// The bar is candle-granular: at most one bar closes per candle, and the candle
|
||||
/// that crosses the threshold closes the bar with its overshoot included.
|
||||
/// [`BarBuilder::update`] returns either an empty vector or a single [`DollarBar`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{BarBuilder, Candle, DollarBars};
|
||||
///
|
||||
/// let c = |cl, v| Candle::new(cl, cl, cl, cl, v, 0).unwrap();
|
||||
/// let mut bars = DollarBars::new(1000.0).unwrap();
|
||||
/// assert!(bars.update(c(10.0, 60.0)).is_empty()); // 600
|
||||
/// let out = bars.update(c(10.0, 60.0)); // 1200 >= 1000 -> close
|
||||
/// assert_eq!(out.len(), 1);
|
||||
/// assert_eq!(out[0].dollar, 1200.0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DollarBars {
|
||||
dollar_per_bar: f64,
|
||||
count: usize,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
volume: f64,
|
||||
dollar: f64,
|
||||
}
|
||||
|
||||
impl DollarBars {
|
||||
/// Construct a dollar-bar builder with the given traded-value threshold.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `dollar_per_bar` is not finite and positive.
|
||||
pub fn new(dollar_per_bar: f64) -> Result<Self> {
|
||||
if !dollar_per_bar.is_finite() || dollar_per_bar <= 0.0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "dollar_per_bar must be finite and positive",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
dollar_per_bar,
|
||||
count: 0,
|
||||
open: 0.0,
|
||||
high: 0.0,
|
||||
low: 0.0,
|
||||
close: 0.0,
|
||||
volume: 0.0,
|
||||
dollar: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured traded-value threshold per bar.
|
||||
pub const fn dollar_per_bar(&self) -> f64 {
|
||||
self.dollar_per_bar
|
||||
}
|
||||
|
||||
/// Traded value accumulated into the in-progress bar.
|
||||
pub const fn accumulated(&self) -> f64 {
|
||||
self.dollar
|
||||
}
|
||||
}
|
||||
|
||||
impl BarBuilder for DollarBars {
|
||||
type Bar = DollarBar;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Vec<DollarBar> {
|
||||
if self.count == 0 {
|
||||
self.open = candle.open;
|
||||
self.high = candle.high;
|
||||
self.low = candle.low;
|
||||
self.volume = 0.0;
|
||||
} else {
|
||||
self.high = self.high.max(candle.high);
|
||||
self.low = self.low.min(candle.low);
|
||||
}
|
||||
self.close = candle.close;
|
||||
self.volume += candle.volume;
|
||||
self.dollar += candle.close * candle.volume;
|
||||
self.count += 1;
|
||||
if self.dollar < self.dollar_per_bar {
|
||||
return Vec::new();
|
||||
}
|
||||
let bar = DollarBar {
|
||||
open: self.open,
|
||||
high: self.high,
|
||||
low: self.low,
|
||||
close: self.close,
|
||||
volume: self.volume,
|
||||
dollar: self.dollar,
|
||||
};
|
||||
self.count = 0;
|
||||
self.dollar = 0.0;
|
||||
vec![bar]
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.count = 0;
|
||||
self.volume = 0.0;
|
||||
self.dollar = 0.0;
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DollarBars"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Candle {
|
||||
Candle::new(open, high, low, close, volume, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_threshold() {
|
||||
assert!(matches!(
|
||||
DollarBars::new(0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
DollarBars::new(-1000.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
DollarBars::new(f64::NAN),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bars = DollarBars::new(50_000.0).unwrap();
|
||||
assert_relative_eq!(bars.dollar_per_bar(), 50_000.0, epsilon = 1e-6);
|
||||
assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12);
|
||||
assert_eq!(bars.name(), "DollarBars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_when_value_reached() {
|
||||
let mut bars = DollarBars::new(1000.0).unwrap();
|
||||
assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)).is_empty()); // 600
|
||||
let out = bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0)); // 1200
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_relative_eq!(out[0].dollar, 1200.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(out[0].volume, 120.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_ohlc() {
|
||||
let mut bars = DollarBars::new(1000.0).unwrap();
|
||||
bars.update(candle(10.0, 11.0, 9.0, 10.0, 50.0)); // 500
|
||||
let out = bars.update(candle(10.0, 12.0, 9.5, 11.0, 60.0)); // 500 + 660 = 1160
|
||||
assert_relative_eq!(out[0].open, 10.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[0].high, 12.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[0].low, 9.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(out[0].close, 11.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_threshold_emits_nothing() {
|
||||
let mut bars = DollarBars::new(1000.0).unwrap();
|
||||
bars.update(candle(10.0, 10.0, 10.0, 10.0, 30.0)); // 300
|
||||
assert_relative_eq!(bars.accumulated(), 300.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bars = DollarBars::new(1000.0).unwrap();
|
||||
bars.update(candle(10.0, 10.0, 10.0, 10.0, 60.0));
|
||||
bars.reset();
|
||||
assert_relative_eq!(bars.accumulated(), 0.0, epsilon = 1e-12);
|
||||
assert!(bars.update(candle(20.0, 20.0, 20.0, 20.0, 10.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_concatenates_completed_bars() {
|
||||
let mut bars = DollarBars::new(1000.0).unwrap();
|
||||
let candles = [
|
||||
candle(10.0, 10.0, 10.0, 10.0, 60.0),
|
||||
candle(10.0, 10.0, 10.0, 10.0, 60.0),
|
||||
candle(10.0, 10.0, 10.0, 10.0, 60.0),
|
||||
candle(10.0, 10.0, 10.0, 10.0, 60.0),
|
||||
];
|
||||
let out = bars.batch(&candles);
|
||||
assert_eq!(out.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Dumpling Top — a rounded top (dome) confirmed by a breakdown.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Dumpling Top — the bearish mirror of the [`FryPanBottom`](crate::FryPanBottom):
|
||||
/// a gently rounded **top** (dome) across the window, confirmed by a close back
|
||||
/// below where it started.
|
||||
///
|
||||
/// ```text
|
||||
/// over the last `period` closes:
|
||||
/// the maximum close sits in the middle third of the window (the "dome")
|
||||
/// the latest close is below the first close (the breakdown)
|
||||
/// signal = −1 when both hold, else 0
|
||||
/// ```
|
||||
///
|
||||
/// The dumpling top is a distribution pattern: price rounds over at the top as
|
||||
/// buying fades, then rolls down through the level it rose from. Detection requires
|
||||
/// a *central* high (a symmetric dome, not a one-sided spike) and a close below the
|
||||
/// window's opening level. The output is `−1.0` (pattern) or `0.0`.
|
||||
///
|
||||
/// The first value lands after `period` inputs; each `update` scans the window in
|
||||
/// O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, DumplingTop};
|
||||
///
|
||||
/// let mut indicator = DumplingTop::new(9).unwrap();
|
||||
/// let closes = [100.0, 102.0, 104.0, 105.0, 104.0, 102.0, 99.0, 97.0, 95.0];
|
||||
/// let mut last = None;
|
||||
/// for &cl in &closes {
|
||||
/// let c = Candle::new(cl, cl + 0.5, cl - 0.5, cl, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(-1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DumplingTop {
|
||||
period: usize,
|
||||
closes: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl DumplingTop {
|
||||
/// Construct a Dumpling Top over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 5`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 5 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "dumpling top needs period >= 5",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
closes: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DumplingTop {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.closes.len() == self.period {
|
||||
self.closes.pop_front();
|
||||
}
|
||||
self.closes.push_back(candle.close);
|
||||
if self.closes.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let first = *self.closes.front().expect("non-empty");
|
||||
let last = *self.closes.back().expect("non-empty");
|
||||
let mut max_idx = 0;
|
||||
let mut max_val = f64::NEG_INFINITY;
|
||||
for (i, &v) in self.closes.iter().enumerate() {
|
||||
if v > max_val {
|
||||
max_val = v;
|
||||
max_idx = i;
|
||||
}
|
||||
}
|
||||
let lo = self.period / 4;
|
||||
let hi = self.period - self.period / 4;
|
||||
let dome = max_idx >= lo && max_idx < hi;
|
||||
let broke_down = last < first && last < max_val;
|
||||
let v = if dome && broke_down { -1.0 } else { 0.0 };
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.closes.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DumplingTop"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close + 0.5, close - 0.5, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_small_period() {
|
||||
assert!(matches!(
|
||||
DumplingTop::new(4),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(DumplingTop::new(5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let d = DumplingTop::new(9).unwrap();
|
||||
assert_eq!(d.period(), 9);
|
||||
assert_eq!(d.warmup_period(), 9);
|
||||
assert_eq!(d.name(), "DumplingTop");
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut d = DumplingTop::new(5).unwrap();
|
||||
let out = d.batch(&[c(100.0), c(101.0), c(102.0), c(101.0), c(99.0), c(98.0)]);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_top_then_breakdown_signals() {
|
||||
let mut d = DumplingTop::new(9).unwrap();
|
||||
let closes = [100.0, 102.0, 104.0, 105.0, 104.0, 102.0, 99.0, 97.0, 95.0];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = d.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_rise_is_zero() {
|
||||
let mut d = DumplingTop::new(9).unwrap();
|
||||
let candles: Vec<Candle> = (0..9).map(|i| c(100.0 + f64::from(i))).collect();
|
||||
let last = d.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_breakdown_is_zero() {
|
||||
let mut d = DumplingTop::new(9).unwrap();
|
||||
let closes = [
|
||||
100.0, 102.0, 104.0, 105.0, 104.0, 103.0, 102.0, 101.0, 100.5,
|
||||
];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = d.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut d = DumplingTop::new(5).unwrap();
|
||||
d.batch(&[c(100.0), c(101.0), c(102.0), c(101.0), c(99.0)]);
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.value(), None);
|
||||
assert_eq!(d.update(c(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0))
|
||||
.collect();
|
||||
let batch = DumplingTop::new(9).unwrap().batch(&candles);
|
||||
let mut b = DumplingTop::new(9).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,68 @@ impl Ema {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the EMA has seen no input yet (neither seeded nor mid-warmup).
|
||||
/// Lets composite indicators (e.g. MACD) decide if a fast batch path is safe.
|
||||
pub(crate) fn is_fresh(&self) -> bool {
|
||||
!self.seeded && self.warmup_buf.is_empty()
|
||||
}
|
||||
|
||||
/// Force the EMA into its seeded steady state with `current` as the latest
|
||||
/// value. Used by composite fused batch paths (MACD) to leave each sub-EMA
|
||||
/// where a per-tick `update` replay would, so a later `update` continues
|
||||
/// correctly. The post-seed recurrence never re-reads `warmup_buf`, so it is
|
||||
/// left as-is.
|
||||
pub(crate) fn seed_to(&mut self, current: f64) {
|
||||
self.current = current;
|
||||
self.seeded = true;
|
||||
}
|
||||
|
||||
/// Vectorized batch returning one `f64` per input (`NaN` during warmup).
|
||||
///
|
||||
/// Shadows the generic [`BatchNanExt::batch_nan`](crate::BatchNanExt) blanket
|
||||
/// default via inherent-method resolution. For a fresh indicator over an
|
||||
/// all-finite slice it runs the seed (mean of the first `period`) once and
|
||||
/// then the bare `alpha * x + (1 - alpha) * prev` recurrence in a tight loop
|
||||
/// with no per-element `is_finite`/`seeded` branch and no `Option` — yet uses
|
||||
/// the identical `mul_add`, so the result is *bit-for-bit* equal to replaying
|
||||
/// `update`. Any other state, or a non-finite element, defers to the exact
|
||||
/// `update` replay.
|
||||
pub fn batch_nan(&mut self, inputs: &[f64]) -> Vec<f64> {
|
||||
let p = self.period;
|
||||
if self.seeded || !self.warmup_buf.is_empty() || !inputs.iter().all(|x| x.is_finite()) {
|
||||
return inputs
|
||||
.iter()
|
||||
.map(|&x| self.update(x).unwrap_or(f64::NAN))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let n = inputs.len();
|
||||
if n < p {
|
||||
// Not enough to seed; mirror `update` stashing inputs for warmup.
|
||||
self.warmup_buf.extend_from_slice(inputs);
|
||||
return vec![f64::NAN; n];
|
||||
}
|
||||
|
||||
// Warmup `[0, p-1)` is `NaN`; values from the seed on are pushed once each.
|
||||
let mut out = vec![f64::NAN; p - 1];
|
||||
out.reserve(n - (p - 1));
|
||||
let seed = inputs[..p].iter().copied().sum::<f64>() / p as f64;
|
||||
let mut cur = seed;
|
||||
out.push(seed);
|
||||
let (alpha, oma) = (self.alpha, self.one_minus_alpha);
|
||||
for &x in &inputs[p..] {
|
||||
cur = alpha.mul_add(x, oma * cur);
|
||||
out.push(cur);
|
||||
}
|
||||
|
||||
// Leave state exactly where `update` would: seeded on `current`, with the
|
||||
// first `period` inputs retained in `warmup_buf` (never cleared post-seed).
|
||||
self.current = cur;
|
||||
self.seeded = true;
|
||||
self.warmup_buf.extend_from_slice(&inputs[..p]);
|
||||
out
|
||||
}
|
||||
|
||||
/// Internal helper that feeds a value without finiteness validation. The caller
|
||||
/// guarantees `input.is_finite()`. Used by MACD which has already validated.
|
||||
pub(crate) fn step_unchecked(&mut self, input: f64) -> Option<f64> {
|
||||
@@ -288,6 +350,71 @@ mod tests {
|
||||
assert_eq!(ema.update(f64::INFINITY), before);
|
||||
}
|
||||
|
||||
fn bits_eq(a: &[f64], b: &[f64]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter()
|
||||
.zip(b)
|
||||
.all(|(x, y)| x == y || (x.is_nan() && y.is_nan()))
|
||||
}
|
||||
|
||||
fn ema_replay(period: usize, series: &[f64]) -> Vec<f64> {
|
||||
let mut e = Ema::new(period).unwrap();
|
||||
series
|
||||
.iter()
|
||||
.map(|&x| e.update(x).unwrap_or(f64::NAN))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_nan_fast_path_is_bit_identical() {
|
||||
let series: Vec<f64> = (0..300)
|
||||
.map(|i| (f64::from(i) * 0.25).cos() * 8.0 + 40.0)
|
||||
.collect();
|
||||
let mut ema = Ema::new(14).unwrap();
|
||||
let got = ema.batch_nan(&series);
|
||||
assert!(bits_eq(&got, &ema_replay(14, &series)));
|
||||
let mut ref_ema = Ema::new(14).unwrap();
|
||||
for &x in &series {
|
||||
ref_ema.update(x);
|
||||
}
|
||||
assert_eq!(ema.update(7.5), ref_ema.update(7.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_nan_falls_back_on_non_finite() {
|
||||
let series = [1.0, 2.0, 3.0, f64::INFINITY, 5.0, 6.0, 7.0];
|
||||
let mut ema = Ema::new(3).unwrap();
|
||||
assert!(bits_eq(&ema.batch_nan(&series), &ema_replay(3, &series)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_nan_falls_back_when_warming() {
|
||||
let mut ema = Ema::new(3).unwrap();
|
||||
ema.update(10.0); // mid-warmup: warmup_buf non-empty, not seeded
|
||||
let series = [1.0, 2.0, 3.0, 4.0];
|
||||
let mut ref_ema = Ema::new(3).unwrap();
|
||||
ref_ema.update(10.0);
|
||||
let want: Vec<f64> = series
|
||||
.iter()
|
||||
.map(|&x| ref_ema.update(x).unwrap_or(f64::NAN))
|
||||
.collect();
|
||||
assert!(bits_eq(&ema.batch_nan(&series), &want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_nan_sub_period_slice_stays_unseeded() {
|
||||
let series = [1.0, 2.0];
|
||||
let mut ema = Ema::new(5).unwrap();
|
||||
let got = ema.batch_nan(&series);
|
||||
assert!(got.iter().all(|x| x.is_nan()) && got.len() == 2);
|
||||
assert!(!ema.is_ready());
|
||||
// Warmup state was stashed: feeding the rest seeds exactly as a full stream.
|
||||
assert!(bits_eq(
|
||||
&[ema.update(3.0).unwrap_or(f64::NAN)],
|
||||
&[ema_replay(5, &[1.0, 2.0, 3.0])[2]]
|
||||
));
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Equivolume — the price box height and its volume-scaled width.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`Equivolume`]: the box's price height and its volume-relative width.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct EquivolumeOutput {
|
||||
/// Box height — the bar's price range `high − low`.
|
||||
pub height: f64,
|
||||
/// Box width — volume relative to its `period` average (`1.0` = average).
|
||||
pub width: f64,
|
||||
}
|
||||
|
||||
/// Equivolume — Richard Arms' charting style rendered as numbers: each bar is a
|
||||
/// "box" whose **height** is its price range and whose **width** is its volume
|
||||
/// relative to the recent average.
|
||||
///
|
||||
/// ```text
|
||||
/// height = high − low
|
||||
/// width = volume / SMA(volume, period) (1.0 = average volume)
|
||||
/// ```
|
||||
///
|
||||
/// Equivolume discards time and substitutes volume for the horizontal axis: a tall
|
||||
/// narrow box is an easy move (big range on light volume), while a short wide box
|
||||
/// is churn (small range on heavy volume) that often marks support/resistance.
|
||||
/// Reporting the two dimensions lets you reconstruct that shape programmatically:
|
||||
/// the height/width relationship is Arms' "ease of movement" read. The width is
|
||||
/// normalised by the volume SMA so it self-scales across instruments.
|
||||
///
|
||||
/// The first value lands after `period` inputs (to seed the volume average). Each
|
||||
/// `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Equivolume};
|
||||
///
|
||||
/// let mut indicator = Equivolume::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base, 1_000.0 + f64::from(i), 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Equivolume {
|
||||
period: usize,
|
||||
vol_sma: Sma,
|
||||
last: Option<EquivolumeOutput>,
|
||||
}
|
||||
|
||||
impl Equivolume {
|
||||
/// Construct an Equivolume with the given volume-averaging `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
vol_sma: Sma::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured volume-averaging period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<EquivolumeOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Equivolume {
|
||||
type Input = Candle;
|
||||
type Output = EquivolumeOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<EquivolumeOutput> {
|
||||
let avg_vol = self.vol_sma.update(candle.volume)?;
|
||||
let height = candle.high - candle.low;
|
||||
let width = if avg_vol > 0.0 {
|
||||
candle.volume / avg_vol
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let out = EquivolumeOutput { height, width };
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.vol_sma.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Equivolume"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, f64::midpoint(high, low), volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Equivolume::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = Equivolume::new(14).unwrap();
|
||||
assert_eq!(e.period(), 14);
|
||||
assert_eq!(e.warmup_period(), 14);
|
||||
assert_eq!(e.name(), "Equivolume");
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(102.0, 98.0, 1_000.0)).collect();
|
||||
let out = e.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_is_range() {
|
||||
let mut e = Equivolume::new(2).unwrap();
|
||||
let out = e
|
||||
.batch(&[c(105.0, 100.0, 1_000.0), c(105.0, 100.0, 1_000.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.height, 5.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_volume_width_is_one() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
let out = e
|
||||
.batch(&[c(102.0, 98.0, 1_000.0); 6])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.width, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavy_bar_is_wide() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
let candles = [
|
||||
c(102.0, 98.0, 1_000.0),
|
||||
c(102.0, 98.0, 1_000.0),
|
||||
c(102.0, 98.0, 4_000.0),
|
||||
];
|
||||
let out = e.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
out.width > 1.0,
|
||||
"a heavy bar should be wider than average, got {}",
|
||||
out.width
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = Equivolume::new(3).unwrap();
|
||||
e.batch(&[c(102.0, 98.0, 1_000.0); 6]);
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
assert_eq!(e.update(c(102.0, 98.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_gives_zero_width() {
|
||||
let mut e = Equivolume::new(2).unwrap();
|
||||
let out = e
|
||||
.batch(&[c(11.0, 9.0, 0.0), c(12.0, 10.0, 0.0), c(13.0, 11.0, 0.0)])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(out.width, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.25).sin() * 5.0,
|
||||
90.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = Equivolume::new(14).unwrap().batch(&candles);
|
||||
let mut b = Equivolume::new(14).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Estimated Leverage Ratio — open interest per unit of aggregate position size.
|
||||
|
||||
use crate::derivatives::DerivativesTick;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Estimated Leverage Ratio (ELR) — open interest relative to the aggregate
|
||||
/// long+short position size, a proxy for how leveraged outstanding positions are.
|
||||
///
|
||||
/// ```text
|
||||
/// ELR = open_interest / (long_size + short_size)
|
||||
/// ```
|
||||
///
|
||||
/// The classic estimated leverage ratio compares open interest (the notional of
|
||||
/// outstanding contracts) to the capital backing it. With the size fields of a
|
||||
/// [`DerivativesTick`] standing in for the position base, the ratio rises when a
|
||||
/// given pool of positions controls more open interest — i.e. when the market is
|
||||
/// running hotter leverage. Spikes in ELR mark crowded, fragile conditions where a
|
||||
/// move can cascade into liquidations; a falling ELR marks deleveraging.
|
||||
///
|
||||
/// The ratio is non-negative; a tick with zero aggregate size reports `0` rather
|
||||
/// than dividing by zero. It is stateless — each tick yields one value (no warmup).
|
||||
/// Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DerivativesTick, Indicator, EstimatedLeverageRatio};
|
||||
///
|
||||
/// let mut indicator = EstimatedLeverageRatio::new();
|
||||
/// let tick = DerivativesTick::new(0.0001, 100.0, 100.0, 100.0, 1_000.0, 400.0, 600.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap();
|
||||
/// let elr = indicator.update(tick).unwrap();
|
||||
/// assert!((elr - 1.0).abs() < 1e-12); // 1000 / (400 + 600)
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EstimatedLeverageRatio {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl EstimatedLeverageRatio {
|
||||
/// Construct a new Estimated Leverage Ratio. The indicator is parameter-free.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { ready: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for EstimatedLeverageRatio {
|
||||
type Input = DerivativesTick;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
|
||||
let base = tick.long_size + tick.short_size;
|
||||
let elr = if base > 0.0 {
|
||||
tick.open_interest / base
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.ready = true;
|
||||
Some(elr)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"EstimatedLeverageRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn tick(oi: f64, long: f64, short: f64) -> DerivativesTick {
|
||||
DerivativesTick::new_unchecked(
|
||||
0.0, 100.0, 100.0, 100.0, oi, long, short, 0.0, 0.0, 0.0, 0.0, 0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = EstimatedLeverageRatio::new();
|
||||
assert_eq!(e.warmup_period(), 1);
|
||||
assert_eq!(e.name(), "EstimatedLeverageRatio");
|
||||
assert!(!e.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ratio_reference_value() {
|
||||
let mut e = EstimatedLeverageRatio::new();
|
||||
// 1000 / (400 + 600) = 1.0.
|
||||
assert_relative_eq!(
|
||||
e.update(tick(1_000.0, 400.0, 600.0)).unwrap(),
|
||||
1.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_oi_raises_ratio() {
|
||||
let mut e = EstimatedLeverageRatio::new();
|
||||
let low = e.update(tick(1_000.0, 500.0, 500.0)).unwrap();
|
||||
let high = e.update(tick(3_000.0, 500.0, 500.0)).unwrap();
|
||||
assert!(high > low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_base_is_zero() {
|
||||
let mut e = EstimatedLeverageRatio::new();
|
||||
assert_relative_eq!(
|
||||
e.update(tick(1_000.0, 0.0, 0.0)).unwrap(),
|
||||
0.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_after_first_update() {
|
||||
let mut e = EstimatedLeverageRatio::new();
|
||||
assert!(!e.is_ready());
|
||||
e.update(tick(1_000.0, 500.0, 500.0));
|
||||
assert!(e.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = EstimatedLeverageRatio::new();
|
||||
e.update(tick(1_000.0, 500.0, 500.0));
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let ticks: Vec<DerivativesTick> = (0..40)
|
||||
.map(|i| tick(1_000.0 + f64::from(i) * 10.0, 500.0, 500.0))
|
||||
.collect();
|
||||
let batch = EstimatedLeverageRatio::new().batch(&ticks);
|
||||
let mut b = EstimatedLeverageRatio::new();
|
||||
let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Ehlers Even Better Sinewave (EBSW) — a normalised cycle oscillator in [-1, 1].
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::super_smoother::SuperSmoother;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' **Even Better Sinewave** (EBSW) — a self-normalising cycle oscillator
|
||||
/// that swings cleanly in `[−1, +1]` regardless of price amplitude.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013, ch. 12):
|
||||
///
|
||||
/// ```text
|
||||
/// alpha1 = (1 − sin(2π/hp_period)) / cos(2π/hp_period)
|
||||
/// HP_t = 0.5·(1 + alpha1)·(price_t − price_{t−1}) + alpha1·HP_{t−1} (one-pole highpass)
|
||||
/// Filt = SuperSmoother(HP, ssf_length)
|
||||
/// Wave = (Filt_t + Filt_{t−1} + Filt_{t−2}) / 3
|
||||
/// Pwr = (Filt_t² + Filt_{t−1}² + Filt_{t−2}²) / 3
|
||||
/// EBSW = Wave / sqrt(Pwr)
|
||||
/// ```
|
||||
///
|
||||
/// The price is first highpass-filtered to remove the trend, then SuperSmoothed to
|
||||
/// remove noise, leaving the dominant cycle. Dividing a 3-bar average of that
|
||||
/// cycle by its RMS power normalises the amplitude, so the output reads like a
|
||||
/// clean sine wave bounded in `[−1, +1]` whatever the instrument. Unlike the
|
||||
/// classic [`SineWave`](crate::SineWave) (which derives in-phase/quadrature
|
||||
/// components from the Hilbert transform and can whip in trends), the EBSW stays
|
||||
/// well-behaved and is read directly: crossing up through `0`/`−0.9` is a buy
|
||||
/// cue, crossing down through `0`/`+0.9` a sell cue.
|
||||
///
|
||||
/// The first value lands once three SuperSmoothed samples exist
|
||||
/// (`warmup_period == 3`). Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, EvenBetterSinewave};
|
||||
///
|
||||
/// let mut indicator = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvenBetterSinewave {
|
||||
hp_period: usize,
|
||||
ssf_length: usize,
|
||||
alpha1: f64,
|
||||
smoother: SuperSmoother,
|
||||
prev_price: Option<f64>,
|
||||
hp: f64,
|
||||
filt1: Option<f64>,
|
||||
filt2: Option<f64>,
|
||||
filt3: Option<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl EvenBetterSinewave {
|
||||
/// Construct an EBSW with the given highpass `hp_period` and SuperSmoother
|
||||
/// `ssf_length`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either argument is `0`.
|
||||
pub fn new(hp_period: usize, ssf_length: usize) -> Result<Self> {
|
||||
if hp_period == 0 || ssf_length == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
let w = 2.0 * PI / hp_period as f64;
|
||||
let alpha1 = (1.0 - w.sin()) / w.cos();
|
||||
Ok(Self {
|
||||
hp_period,
|
||||
ssf_length,
|
||||
alpha1,
|
||||
smoother: SuperSmoother::new(ssf_length)?,
|
||||
prev_price: None,
|
||||
hp: 0.0,
|
||||
filt1: None,
|
||||
filt2: None,
|
||||
filt3: None,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(hp_period, ssf_length)`.
|
||||
pub const fn params(&self) -> (usize, usize) {
|
||||
(self.hp_period, self.ssf_length)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for EvenBetterSinewave {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let hp = match self.prev_price {
|
||||
Some(prev) => 0.5 * (1.0 + self.alpha1) * (price - prev) + self.alpha1 * self.hp,
|
||||
None => 0.0,
|
||||
};
|
||||
self.prev_price = Some(price);
|
||||
self.hp = hp;
|
||||
let filt = self.smoother.update(hp)?;
|
||||
// Shift the three-deep filter buffer.
|
||||
self.filt3 = self.filt2;
|
||||
self.filt2 = self.filt1;
|
||||
self.filt1 = Some(filt);
|
||||
let (Some(f1), Some(f2), Some(f3)) = (self.filt1, self.filt2, self.filt3) else {
|
||||
return None;
|
||||
};
|
||||
let wave = (f1 + f2 + f3) / 3.0;
|
||||
let pwr = (f1 * f1 + f2 * f2 + f3 * f3) / 3.0;
|
||||
let ebsw = if pwr > 0.0 {
|
||||
(wave / pwr.sqrt()).clamp(-1.0, 1.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(ebsw);
|
||||
Some(ebsw)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.smoother.reset();
|
||||
self.prev_price = None;
|
||||
self.hp = 0.0;
|
||||
self.filt1 = None;
|
||||
self.filt2 = None;
|
||||
self.filt3 = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"EvenBetterSinewave"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_params() {
|
||||
assert!(matches!(
|
||||
EvenBetterSinewave::new(0, 10),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
EvenBetterSinewave::new(40, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
assert_eq!(e.params(), (40, 10));
|
||||
assert_eq!(e.warmup_period(), 3);
|
||||
assert_eq!(e.name(), "EvenBetterSinewave");
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
let xs: Vec<f64> = (0..12)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 3.0)
|
||||
.collect();
|
||||
let out = e.batch(&xs);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
|
||||
.collect();
|
||||
for v in e.batch(&xs).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v), "EBSW out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyclic_input_swings_both_signs() {
|
||||
let mut e = EvenBetterSinewave::new(30, 8).unwrap();
|
||||
let xs: Vec<f64> = (0..400)
|
||||
.map(|i| 100.0 + (std::f64::consts::TAU * f64::from(i) / 30.0).sin() * 5.0)
|
||||
.collect();
|
||||
let out: Vec<f64> = e.batch(&xs).into_iter().flatten().skip(100).collect();
|
||||
assert!(out.iter().any(|&v| v > 0.5));
|
||||
assert!(out.iter().any(|&v| v < -0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
e.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let before = e.value();
|
||||
assert_eq!(e.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
e.batch(
|
||||
&(0..40)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
assert_eq!(e.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = EvenBetterSinewave::new(40, 10).unwrap().batch(&xs);
|
||||
let mut b = EvenBetterSinewave::new(40, 10).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_input_yields_zero_power() {
|
||||
// A constant series drives the highpass/smoother outputs to zero, so the
|
||||
// signal power is zero and the oscillator reports 0.0 (the `pwr == 0` arm).
|
||||
let flat = [100.0_f64; 200];
|
||||
let last = EvenBetterSinewave::new(40, 10)
|
||||
.unwrap()
|
||||
.batch(&flat)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Frying Pan Bottom — a rounded bottom (U) confirmed by recovery.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Frying Pan Bottom — a gently rounded bottom across the lookback window: prices
|
||||
/// decline, flatten near the centre, then recover above where they started.
|
||||
///
|
||||
/// ```text
|
||||
/// over the last `period` closes:
|
||||
/// the minimum close sits in the middle third of the window (the "bowl")
|
||||
/// the latest close is above the first close (the rim is recovered)
|
||||
/// signal = +1 when both hold, else 0
|
||||
/// ```
|
||||
///
|
||||
/// The frying pan is a bullish accumulation pattern: a saucer-shaped base where
|
||||
/// selling dries up, the curve flattens, and price lifts off the rim. Detecting it
|
||||
/// requires the low point to be central (a symmetric bowl, not a one-sided drop)
|
||||
/// and the close to have climbed back above the window's opening level, confirming
|
||||
/// the breakout from the base. The output is `+1.0` (pattern) or `0.0`.
|
||||
///
|
||||
/// The first value lands after `period` inputs; each `update` scans the window in
|
||||
/// O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, FryPanBottom};
|
||||
///
|
||||
/// let mut indicator = FryPanBottom::new(9).unwrap();
|
||||
/// // A U-shaped base then recovery.
|
||||
/// let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 98.0, 101.0, 103.0, 105.0];
|
||||
/// let mut last = None;
|
||||
/// for &cl in &closes {
|
||||
/// let c = Candle::new(cl, cl + 0.5, cl - 0.5, cl, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FryPanBottom {
|
||||
period: usize,
|
||||
closes: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl FryPanBottom {
|
||||
/// Construct a Frying Pan Bottom over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 5` (a bowl needs room for a
|
||||
/// central low between recovering sides).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 5 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "frying pan bottom needs period >= 5",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
closes: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FryPanBottom {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.closes.len() == self.period {
|
||||
self.closes.pop_front();
|
||||
}
|
||||
self.closes.push_back(candle.close);
|
||||
if self.closes.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let first = *self.closes.front().expect("non-empty");
|
||||
let last = *self.closes.back().expect("non-empty");
|
||||
// Index of the minimum close.
|
||||
let mut min_idx = 0;
|
||||
let mut min_val = f64::INFINITY;
|
||||
for (i, &v) in self.closes.iter().enumerate() {
|
||||
if v < min_val {
|
||||
min_val = v;
|
||||
min_idx = i;
|
||||
}
|
||||
}
|
||||
let lo = self.period / 4;
|
||||
let hi = self.period - self.period / 4;
|
||||
let bowl = min_idx >= lo && min_idx < hi;
|
||||
let recovered = last > first && last > min_val;
|
||||
let v = if bowl && recovered { 1.0 } else { 0.0 };
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.closes.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FryPanBottom"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(close: f64) -> Candle {
|
||||
Candle::new_unchecked(close, close + 0.5, close - 0.5, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_small_period() {
|
||||
assert!(matches!(
|
||||
FryPanBottom::new(4),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(FryPanBottom::new(5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let f = FryPanBottom::new(9).unwrap();
|
||||
assert_eq!(f.period(), 9);
|
||||
assert_eq!(f.warmup_period(), 9);
|
||||
assert_eq!(f.name(), "FryPanBottom");
|
||||
assert!(!f.is_ready());
|
||||
assert_eq!(f.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut f = FryPanBottom::new(5).unwrap();
|
||||
let out = f.batch(&[c(100.0), c(99.0), c(98.0), c(99.0), c(101.0), c(102.0)]);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_bottom_then_recovery_signals() {
|
||||
let mut f = FryPanBottom::new(9).unwrap();
|
||||
let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 98.0, 101.0, 103.0, 105.0];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = f.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_sided_drop_is_zero() {
|
||||
// A straight decline (min at the end) is not a bowl.
|
||||
let mut f = FryPanBottom::new(9).unwrap();
|
||||
let candles: Vec<Candle> = (0..9).map(|i| c(100.0 - f64::from(i))).collect();
|
||||
let last = f.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_recovery_is_zero() {
|
||||
// Bowl shape but the last close never climbs above the first.
|
||||
let mut f = FryPanBottom::new(9).unwrap();
|
||||
let closes = [100.0, 98.0, 96.0, 95.0, 96.0, 97.0, 98.0, 99.0, 99.5];
|
||||
let candles: Vec<Candle> = closes.iter().map(|&x| c(x)).collect();
|
||||
let last = f.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut f = FryPanBottom::new(5).unwrap();
|
||||
f.batch(&[c(100.0), c(99.0), c(98.0), c(99.0), c(101.0)]);
|
||||
assert!(f.is_ready());
|
||||
f.reset();
|
||||
assert!(!f.is_ready());
|
||||
assert_eq!(f.value(), None);
|
||||
assert_eq!(f.update(c(100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| c(100.0 + (f64::from(i) * 0.3).sin() * 5.0))
|
||||
.collect();
|
||||
let batch = FryPanBottom::new(9).unwrap().batch(&candles);
|
||||
let mut b = FryPanBottom::new(9).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Funding-Implied APR — the per-interval funding rate annualised.
|
||||
|
||||
use crate::derivatives::DerivativesTick;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Funding-Implied APR — the perpetual's per-interval funding rate scaled to an
|
||||
/// annualised rate.
|
||||
///
|
||||
/// ```text
|
||||
/// APR = funding_rate · intervals_per_year
|
||||
/// ```
|
||||
///
|
||||
/// Funding is paid in small per-interval amounts (commonly every 8 hours, i.e.
|
||||
/// `1095` intervals per year). Annualising it converts the headline funding number
|
||||
/// into the carry cost (or yield) of holding the position for a year, which is far
|
||||
/// easier to reason about and to compare against spot lending rates, basis trades,
|
||||
/// and other yields. A large positive APR means longs pay a steep carry to shorts
|
||||
/// (and vice versa) — the economic incentive behind cash-and-carry and
|
||||
/// funding-arbitrage strategies.
|
||||
///
|
||||
/// The output is a fraction (multiply by `100` for percent) and may be negative.
|
||||
/// It is stateless — each tick yields one value (no warmup). Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DerivativesTick, Indicator, FundingImpliedApr};
|
||||
///
|
||||
/// // 0.01% per 8h funding -> 0.0001 * 1095 ≈ 10.95% APR.
|
||||
/// let mut indicator = FundingImpliedApr::new(1095.0).unwrap();
|
||||
/// let tick = DerivativesTick::new(0.0001, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap();
|
||||
/// let apr = indicator.update(tick).unwrap();
|
||||
/// assert!((apr - 0.1095).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FundingImpliedApr {
|
||||
intervals_per_year: f64,
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl FundingImpliedApr {
|
||||
/// Construct a Funding-Implied APR with the number of funding intervals per
|
||||
/// year (e.g. `1095` for 8-hour funding, `365` for daily).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidParameter`] if `intervals_per_year` is not finite
|
||||
/// and positive.
|
||||
pub fn new(intervals_per_year: f64) -> Result<Self> {
|
||||
if !intervals_per_year.is_finite() || intervals_per_year <= 0.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "intervals_per_year must be finite and positive",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
intervals_per_year,
|
||||
ready: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured intervals per year.
|
||||
pub const fn intervals_per_year(&self) -> f64 {
|
||||
self.intervals_per_year
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FundingImpliedApr {
|
||||
type Input = DerivativesTick;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
|
||||
self.ready = true;
|
||||
Some(tick.funding_rate * self.intervals_per_year)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FundingImpliedApr"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn tick(funding: f64) -> DerivativesTick {
|
||||
DerivativesTick::new_unchecked(
|
||||
funding, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_intervals() {
|
||||
assert!(matches!(
|
||||
FundingImpliedApr::new(0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
FundingImpliedApr::new(-1.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let f = FundingImpliedApr::new(1095.0).unwrap();
|
||||
assert_relative_eq!(f.intervals_per_year(), 1095.0, epsilon = 1e-12);
|
||||
assert_eq!(f.warmup_period(), 1);
|
||||
assert_eq!(f.name(), "FundingImpliedApr");
|
||||
assert!(!f.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apr_reference_value() {
|
||||
let mut f = FundingImpliedApr::new(1095.0).unwrap();
|
||||
assert_relative_eq!(f.update(tick(0.0001)).unwrap(), 0.1095, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_funding_is_negative_apr() {
|
||||
let mut f = FundingImpliedApr::new(1095.0).unwrap();
|
||||
assert!(f.update(tick(-0.0001)).unwrap() < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_funding_is_zero() {
|
||||
let mut f = FundingImpliedApr::new(365.0).unwrap();
|
||||
assert_relative_eq!(f.update(tick(0.0)).unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut f = FundingImpliedApr::new(1095.0).unwrap();
|
||||
f.update(tick(0.0001));
|
||||
assert!(f.is_ready());
|
||||
f.reset();
|
||||
assert!(!f.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let ticks: Vec<DerivativesTick> = (0..40)
|
||||
.map(|i| tick(0.0001 * (f64::from(i) * 0.3).sin()))
|
||||
.collect();
|
||||
let batch = FundingImpliedApr::new(1095.0).unwrap().batch(&ticks);
|
||||
let mut b = FundingImpliedApr::new(1095.0).unwrap();
|
||||
let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Gain-to-Pain Ratio (Schwager) — sum of returns over the sum of losses.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Gain-to-Pain Ratio — Jack Schwager's measure of return per unit of downside:
|
||||
/// the sum of all returns divided by the sum of the absolute *negative* returns.
|
||||
///
|
||||
/// ```text
|
||||
/// GPR = Σ returns / Σ |negative returns| over the window
|
||||
/// ```
|
||||
///
|
||||
/// Where the [`GainLossRatio`](crate::GainLossRatio) compares *average* win to
|
||||
/// *average* loss and the [`ProfitFactor`](crate::ProfitFactor) compares gross
|
||||
/// profit to gross loss, the Gain-to-Pain Ratio puts the **net** result over the
|
||||
/// total pain endured to earn it. Schwager treats a GPR above `1.0` as good and
|
||||
/// above `2.0` as excellent for a monthly return series: the strategy made more
|
||||
/// than it lost on the way, and twice as much when GPR is `2`. A flat series, or
|
||||
/// one with no losses, has no measurable pain and reports `0` (undefined).
|
||||
///
|
||||
/// The output is unbounded and may be negative (a net-losing window). The first
|
||||
/// value lands after `period` returns; each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, GainToPainRatio};
|
||||
///
|
||||
/// let mut indicator = GainToPainRatio::new(12).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..24 {
|
||||
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.02);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GainToPainRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
sum_all: f64,
|
||||
sum_pain: f64,
|
||||
}
|
||||
|
||||
impl GainToPainRatio {
|
||||
/// Construct a Gain-to-Pain Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_all: 0.0,
|
||||
sum_pain: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for GainToPainRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return if self.window.len() == self.period {
|
||||
Some(self.compute())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("non-empty");
|
||||
self.sum_all -= old;
|
||||
if old < 0.0 {
|
||||
self.sum_pain -= -old;
|
||||
}
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
self.sum_all += ret;
|
||||
if ret < 0.0 {
|
||||
self.sum_pain += -ret;
|
||||
}
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_all = 0.0;
|
||||
self.sum_pain = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"GainToPainRatio"
|
||||
}
|
||||
}
|
||||
|
||||
impl GainToPainRatio {
|
||||
fn compute(&self) -> f64 {
|
||||
if self.sum_pain > 0.0 {
|
||||
self.sum_all / self.sum_pain
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(GainToPainRatio::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let g = GainToPainRatio::new(12).unwrap();
|
||||
assert_eq!(g.period(), 12);
|
||||
assert_eq!(g.warmup_period(), 12);
|
||||
assert_eq!(g.name(), "GainToPainRatio");
|
||||
assert!(!g.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut g = GainToPainRatio::new(4).unwrap();
|
||||
let out = g.batch(&[0.01, -0.01, 0.02, -0.01, 0.03]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns: +0.04, -0.02 -> sum_all = 0.02, pain = 0.02 -> GPR = 1.0.
|
||||
let mut g = GainToPainRatio::new(2).unwrap();
|
||||
let out = g.batch(&[0.04, -0.02]);
|
||||
assert_relative_eq!(out[1].unwrap(), 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_losing_window_is_negative() {
|
||||
let mut g = GainToPainRatio::new(3).unwrap();
|
||||
let last = g
|
||||
.batch(&[-0.03, 0.01, -0.02])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pain_is_zero() {
|
||||
let mut g = GainToPainRatio::new(3).unwrap();
|
||||
let last = g
|
||||
.batch(&[0.01, 0.02, 0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut g = GainToPainRatio::new(2).unwrap();
|
||||
let ready = g
|
||||
.batch(&[0.04, -0.02])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(g.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_before_ready_is_none() {
|
||||
// A non-finite value arriving before the window fills yields None.
|
||||
let mut g = GainToPainRatio::new(3).unwrap();
|
||||
assert_eq!(g.update(0.02), None);
|
||||
assert_eq!(g.update(f64::NAN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut g = GainToPainRatio::new(2).unwrap();
|
||||
g.batch(&[0.04, -0.02]);
|
||||
assert!(g.is_ready());
|
||||
g.reset();
|
||||
assert!(!g.is_ready());
|
||||
assert_eq!(g.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.3).sin() * 0.02).collect();
|
||||
let batch = GainToPainRatio::new(12).unwrap().batch(&rets);
|
||||
let mut b = GainToPainRatio::new(12).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| b.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
//! Harami Cross — a Harami whose second candle is a Doji.
|
||||
//!
|
||||
//! A Harami Cross is a stronger Harami: a large real body followed by a Doji whose
|
||||
//! body sits *within* the prior body. The Doji's total indecision after a strong
|
||||
//! move makes the reversal signal more potent than a plain Harami.
|
||||
//!
|
||||
//! - **Bullish** (`+1.0`): the prior candle is a large **bearish** body
|
||||
//! (`close < open`) and the current candle is a Doji whose open and close lie
|
||||
//! within the prior body.
|
||||
//! - **Bearish** (`-1.0`): the prior candle is a large **bullish** body and the
|
||||
//! current is a contained Doji.
|
||||
//! - Otherwise the output is `0.0`.
|
||||
//!
|
||||
//! A doji is a candle whose body is `<= 0.1 * range`. The two-bar lookback means
|
||||
//! the first value lands on the second candle.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
fn is_doji(candle: Candle) -> bool {
|
||||
let body = (candle.close - candle.open).abs();
|
||||
let range = candle.high - candle.low;
|
||||
range > 0.0 && body <= 0.1 * range
|
||||
}
|
||||
|
||||
/// Harami Cross — large-body-then-contained-doji reversal detector.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HaramiCross {
|
||||
prev: Option<Candle>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl HaramiCross {
|
||||
/// Construct a new `HaramiCross`.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Latest emitted signal if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HaramiCross {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(0.0);
|
||||
return Some(0.0);
|
||||
};
|
||||
let prev_body_low = prev.open.min(prev.close);
|
||||
let prev_body_high = prev.open.max(prev.close);
|
||||
let prev_is_solid = !is_doji(prev);
|
||||
let curr_is_doji = is_doji(candle);
|
||||
let contained = candle.open >= prev_body_low
|
||||
&& candle.open <= prev_body_high
|
||||
&& candle.close >= prev_body_low
|
||||
&& candle.close <= prev_body_high;
|
||||
|
||||
let v = if prev_is_solid && curr_is_doji && contained {
|
||||
if prev.close < prev.open {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.prev = Some(candle);
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HaramiCross"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn solid(open: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
open,
|
||||
open.max(close) + 0.2,
|
||||
open.min(close) - 0.2,
|
||||
close,
|
||||
0.0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
fn doji(mid: f64) -> Candle {
|
||||
Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HaramiCross::new();
|
||||
assert_eq!(h.warmup_period(), 2);
|
||||
assert_eq!(h.name(), "HaramiCross");
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_seeds_without_signal() {
|
||||
let mut h = HaramiCross::new();
|
||||
assert_eq!(h.update(solid(110.0, 100.0)), Some(0.0));
|
||||
assert!(h.update(doji(105.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_harami_cross() {
|
||||
// prior big bearish body [100, 110]; doji centred at 105 inside it -> +1.
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
assert_eq!(h.update(doji(105.0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_harami_cross() {
|
||||
// prior big bullish body [100, 110]; doji inside -> -1.
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(100.0, 110.0));
|
||||
assert_eq!(h.update(doji(105.0)), Some(-1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_outside_body_is_zero() {
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
// doji centred at 120, outside the prior body -> 0.
|
||||
assert_eq!(h.update(doji(120.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_doji_second_is_zero() {
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
assert_eq!(h.update(solid(104.0, 106.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HaramiCross::new();
|
||||
h.update(solid(110.0, 100.0));
|
||||
h.update(doji(105.0));
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.update(solid(110.0, 100.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
if i % 2 == 0 {
|
||||
solid(110.0, 100.0)
|
||||
} else {
|
||||
doji(105.0)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let batch = HaramiCross::new().batch(&candles);
|
||||
let mut b = HaramiCross::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Hasbrouck Information Share — each venue's contribution to price discovery.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Hasbrouck Information Share — the share of price-discovery attributable to the
|
||||
/// **first** of two synchronised price series (e.g. the same asset on two venues).
|
||||
///
|
||||
/// ```text
|
||||
/// rx_t = x_t − x_{t−1}, ry_t = y_t − y_{t−1} (one-step price changes)
|
||||
/// IS_x = var(rx) / ( var(rx) + var(ry) ) over the window, ∈ [0, 1]
|
||||
/// ```
|
||||
///
|
||||
/// When the same instrument trades on several venues, Joel Hasbrouck's information
|
||||
/// share measures how much each venue contributes to the common efficient price.
|
||||
/// The venue whose innovations carry more of the variance leads price discovery.
|
||||
/// This streaming form uses the **variance-ratio proxy**: the fraction of total
|
||||
/// return variance contributed by series `x`. A reading above `0.5` means venue
|
||||
/// `x` is the price leader; below `0.5`, the follower. (The full Hasbrouck measure
|
||||
/// estimates a vector error-correction model and reports an upper/lower bound from
|
||||
/// the Cholesky ordering; this proxy captures the leading idea without the VECM.)
|
||||
///
|
||||
/// The output is in `[0, 1]`; if both series are flat it reports the neutral `0.5`.
|
||||
/// The first value lands after `period + 1` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, HasbrouckInformationShare};
|
||||
///
|
||||
/// let mut indicator = HasbrouckInformationShare::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// // Venue x moves a lot, venue y barely moves -> x leads.
|
||||
/// let x = (f64::from(i) * 0.5).sin() * 10.0;
|
||||
/// let y = (f64::from(i) * 0.5).sin() * 1.0;
|
||||
/// last = indicator.update((x, y));
|
||||
/// }
|
||||
/// assert!(last.unwrap() > 0.8);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HasbrouckInformationShare {
|
||||
period: usize,
|
||||
prev: Option<(f64, f64)>,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_x: f64,
|
||||
sum_y: f64,
|
||||
sum_xx: f64,
|
||||
sum_yy: f64,
|
||||
}
|
||||
|
||||
impl HasbrouckInformationShare {
|
||||
/// Construct a Hasbrouck information share over `period` return pairs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (variance needs two
|
||||
/// returns).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "information share needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_x: 0.0,
|
||||
sum_y: 0.0,
|
||||
sum_xx: 0.0,
|
||||
sum_yy: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of return pairs.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HasbrouckInformationShare {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (x, y) = input;
|
||||
let Some((px, py)) = self.prev else {
|
||||
self.prev = Some((x, y));
|
||||
return None;
|
||||
};
|
||||
self.prev = Some((x, y));
|
||||
let (rx, ry) = (x - px, y - py);
|
||||
if self.window.len() == self.period {
|
||||
let (ox, oy) = self.window.pop_front().expect("non-empty");
|
||||
self.sum_x -= ox;
|
||||
self.sum_y -= oy;
|
||||
self.sum_xx -= ox * ox;
|
||||
self.sum_yy -= oy * oy;
|
||||
}
|
||||
self.window.push_back((rx, ry));
|
||||
self.sum_x += rx;
|
||||
self.sum_y += ry;
|
||||
self.sum_xx += rx * rx;
|
||||
self.sum_yy += ry * ry;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let var_x = (self.sum_xx / n - (self.sum_x / n).powi(2)).max(0.0);
|
||||
let var_y = (self.sum_yy / n - (self.sum_y / n).powi(2)).max(0.0);
|
||||
let total = var_x + var_y;
|
||||
Some(if total > 0.0 { var_x / total } else { 0.5 })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.window.clear();
|
||||
self.sum_x = 0.0;
|
||||
self.sum_y = 0.0;
|
||||
self.sum_xx = 0.0;
|
||||
self.sum_yy = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HasbrouckInformationShare"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
HasbrouckInformationShare::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(HasbrouckInformationShare::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HasbrouckInformationShare::new(20).unwrap();
|
||||
assert_eq!(h.period(), 20);
|
||||
assert_eq!(h.warmup_period(), 21);
|
||||
assert_eq!(h.name(), "HasbrouckInformationShare");
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_needs_period_plus_one() {
|
||||
let mut h = HasbrouckInformationShare::new(3).unwrap();
|
||||
assert_eq!(h.update((1.0, 1.0)), None);
|
||||
assert_eq!(h.update((2.0, 2.0)), None);
|
||||
assert_eq!(h.update((3.0, 2.5)), None);
|
||||
assert!(h.update((4.0, 3.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loud_venue_leads() {
|
||||
// x is far more volatile than y -> x holds nearly all the share.
|
||||
let pairs: Vec<(f64, f64)> = (0..40)
|
||||
.map(|i| {
|
||||
(
|
||||
(f64::from(i) * 0.5).sin() * 10.0,
|
||||
(f64::from(i) * 0.5).sin() * 1.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let last = HasbrouckInformationShare::new(20)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last > 0.8, "the loud venue should lead, got {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_venues_split_evenly() {
|
||||
// Independent but equal-variance moves -> share near 0.5.
|
||||
let pairs: Vec<(f64, f64)> = (0..200)
|
||||
.map(|i| {
|
||||
(
|
||||
(f64::from(i) * 0.5).sin() * 5.0,
|
||||
(f64::from(i) * 0.5).cos() * 5.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for v in HasbrouckInformationShare::new(40)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((0.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_series_is_half() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20).map(|_| (7.0, 9.0)).collect();
|
||||
let last = HasbrouckInformationShare::new(5)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HasbrouckInformationShare::new(4).unwrap();
|
||||
h.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0), (5.0, 5.0)]);
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..120)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(t.sin() * 5.0, (t * 0.5).cos() * 3.0)
|
||||
})
|
||||
.collect();
|
||||
let batch = HasbrouckInformationShare::new(20).unwrap().batch(&pairs);
|
||||
let mut h = HasbrouckInformationShare::new(20).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Heikin-Ashi Oscillator — the (smoothed) Heikin-Ashi candle body as a zero-line oscillator.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::heikin_ashi::HeikinAshi;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Heikin-Ashi Oscillator — the body of the [`HeikinAshi`](crate::HeikinAshi)
|
||||
/// candle (`ha_close − ha_open`), optionally EMA-smoothed, as an oscillator around
|
||||
/// zero.
|
||||
///
|
||||
/// ```text
|
||||
/// body = ha_close − ha_open
|
||||
/// HAO = EMA(body, period)
|
||||
/// ```
|
||||
///
|
||||
/// A Heikin-Ashi candle is bullish when its close is above its open and bearish
|
||||
/// when below; the size of that body measures conviction. Plotting the body as an
|
||||
/// oscillator turns the visual HA colour/strength into a number: positive =
|
||||
/// bullish HA candles, negative = bearish, and the magnitude is trend strength.
|
||||
/// Smoothing the body with an EMA (`period`) damps single-bar noise so zero-line
|
||||
/// crosses mark cleaner trend changes. With `period == 1` the oscillator is the raw
|
||||
/// HA body.
|
||||
///
|
||||
/// The output is centred on zero (price units). The first value lands after
|
||||
/// `period` inputs (the HA transform itself needs only one). Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, HeikinAshiOscillator};
|
||||
///
|
||||
/// let mut indicator = HeikinAshiOscillator::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeikinAshiOscillator {
|
||||
period: usize,
|
||||
ha: HeikinAshi,
|
||||
ema: Ema,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl HeikinAshiOscillator {
|
||||
/// Construct a Heikin-Ashi Oscillator with the given EMA smoothing `period`
|
||||
/// (use `1` for the raw body).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
ha: HeikinAshi::new(),
|
||||
ema: Ema::new(period)?,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HeikinAshiOscillator {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let ha = self.ha.update(candle).expect("HeikinAshi emits every bar");
|
||||
let body = ha.close - ha.open;
|
||||
let v = self.ema.update(body)?;
|
||||
self.last = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ha.reset();
|
||||
self.ema.reset();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HeikinAshiOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(open: f64, high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new_unchecked(open, high, low, close, 1_000.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
HeikinAshiOscillator::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HeikinAshiOscillator::new(5).unwrap();
|
||||
assert_eq!(h.period(), 5);
|
||||
assert_eq!(h.warmup_period(), 5);
|
||||
assert_eq!(h.name(), "HeikinAshiOscillator");
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b + 0.5)
|
||||
})
|
||||
.collect();
|
||||
let out = h.batch(&candles);
|
||||
for v in out.iter().take(2) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_is_positive() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let b = 100.0 + 2.0 * f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b + 1.5)
|
||||
})
|
||||
.collect();
|
||||
let last = h.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 0.0,
|
||||
"uptrend should give a positive HA body, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_is_negative() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let b = 200.0 - 2.0 * f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b - 1.5)
|
||||
})
|
||||
.collect();
|
||||
let last = h.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last < 0.0,
|
||||
"downtrend should give a negative HA body, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_near_zero() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
let last = h
|
||||
.batch(&[c(100.0, 100.5, 99.5, 100.0); 30])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HeikinAshiOscillator::new(3).unwrap();
|
||||
h.batch(
|
||||
&(0..10)
|
||||
.map(|i| {
|
||||
let b = 100.0 + f64::from(i);
|
||||
c(b, b + 1.0, b - 1.0, b)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
assert_eq!(h.update(c(100.0, 101.0, 99.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
c(b, b + 1.0, b - 1.0, b + 0.3)
|
||||
})
|
||||
.collect();
|
||||
let batch = HeikinAshiOscillator::new(5).unwrap().batch(&candles);
|
||||
let mut b = HeikinAshiOscillator::new(5).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! High/Low Volume Nodes (HVN / LVN) — the busiest and quietest price levels.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`HighLowVolumeNodes`]: the price of the highest- and lowest-volume
|
||||
/// node in the profile.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct HighLowVolumeNodesOutput {
|
||||
/// High Volume Node — the price level (bin centre) with the most volume.
|
||||
pub hvn: f64,
|
||||
/// Low Volume Node — the traded price level with the least volume.
|
||||
pub lvn: f64,
|
||||
}
|
||||
|
||||
/// High/Low Volume Nodes — the price levels of greatest and least acceptance in a
|
||||
/// rolling volume profile.
|
||||
///
|
||||
/// ```text
|
||||
/// build a `bins`-bucket volume profile over the last `period` candles
|
||||
/// HVN = bin centre of the bucket with the most volume
|
||||
/// LVN = bin centre of the traded bucket with the least volume
|
||||
/// ```
|
||||
///
|
||||
/// A volume profile reveals where the market spent the most effort. A **High Volume
|
||||
/// Node** (HVN) is a price the market accepted and traded heavily — it acts as a
|
||||
/// magnet and as strong support/resistance. A **Low Volume Node** (LVN) is a price
|
||||
/// the market rejected quickly — moves tend to accelerate through LVNs and they
|
||||
/// often mark the edges between balance areas. Each candle's volume is spread
|
||||
/// across the price bins its high-low range spans (as in
|
||||
/// [`VolumeProfile`](crate::VolumeProfile)).
|
||||
///
|
||||
/// The first value lands after `period` candles; each `update` rebuilds the profile
|
||||
/// in O(`period · bins`). A degenerate flat window puts both nodes at the price.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, HighLowVolumeNodes};
|
||||
///
|
||||
/// let mut indicator = HighLowVolumeNodes::new(20, 24).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HighLowVolumeNodes {
|
||||
period: usize,
|
||||
bins: usize,
|
||||
window: VecDeque<Candle>,
|
||||
last: Option<HighLowVolumeNodesOutput>,
|
||||
}
|
||||
|
||||
impl HighLowVolumeNodes {
|
||||
/// Construct a High/Low Volume Nodes indicator.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period` or `bins` is zero.
|
||||
pub fn new(period: usize, bins: usize) -> Result<Self> {
|
||||
if period == 0 || bins == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
bins,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bins)`.
|
||||
pub const fn params(&self) -> (usize, usize) {
|
||||
(self.period, self.bins)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<HighLowVolumeNodesOutput> {
|
||||
self.last
|
||||
}
|
||||
|
||||
/// Build the volume histogram; returns `(low, bin_width, bins)`.
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
fn profile(&self) -> (f64, f64, Vec<f64>) {
|
||||
let mut low = f64::INFINITY;
|
||||
let mut high = f64::NEG_INFINITY;
|
||||
for c in &self.window {
|
||||
low = low.min(c.low);
|
||||
high = high.max(c.high);
|
||||
}
|
||||
let mut hist = vec![0.0; self.bins];
|
||||
let span = high - low;
|
||||
if span <= 0.0 {
|
||||
hist[0] = self.window.iter().map(|c| c.volume).sum();
|
||||
return (low, 0.0, hist);
|
||||
}
|
||||
let width = span / self.bins as f64;
|
||||
for c in &self.window {
|
||||
if c.volume == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let touched = hi_idx - lo_idx + 1;
|
||||
let share = c.volume / touched as f64;
|
||||
for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
|
||||
*bin += share;
|
||||
}
|
||||
}
|
||||
(low, width, hist)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HighLowVolumeNodes {
|
||||
type Input = Candle;
|
||||
type Output = HighLowVolumeNodesOutput;
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
fn update(&mut self, candle: Candle) -> Option<HighLowVolumeNodesOutput> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(candle);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let (low, width, hist) = self.profile();
|
||||
let centre = |idx: usize| low + (idx as f64 + 0.5) * width;
|
||||
|
||||
let mut hvn_idx = 0;
|
||||
let mut hvn_vol = f64::NEG_INFINITY;
|
||||
let mut lvn_idx = 0;
|
||||
let mut lvn_vol = f64::INFINITY;
|
||||
for (idx, &vol) in hist.iter().enumerate() {
|
||||
if vol > hvn_vol {
|
||||
hvn_vol = vol;
|
||||
hvn_idx = idx;
|
||||
}
|
||||
if vol > 0.0 && vol < lvn_vol {
|
||||
lvn_vol = vol;
|
||||
lvn_idx = idx;
|
||||
}
|
||||
}
|
||||
// If no traded bin was found (all zero volume), both default to bin 0.
|
||||
if !lvn_vol.is_finite() {
|
||||
lvn_idx = hvn_idx;
|
||||
}
|
||||
let out = HighLowVolumeNodesOutput {
|
||||
hvn: centre(hvn_idx),
|
||||
lvn: centre(lvn_idx),
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HighLowVolumeNodes"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
f64::midpoint(high, low),
|
||||
high,
|
||||
low,
|
||||
f64::midpoint(high, low),
|
||||
volume,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_params() {
|
||||
assert!(matches!(
|
||||
HighLowVolumeNodes::new(0, 24),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
HighLowVolumeNodes::new(20, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HighLowVolumeNodes::new(20, 24).unwrap();
|
||||
assert_eq!(h.params(), (20, 24));
|
||||
assert_eq!(h.warmup_period(), 20);
|
||||
assert_eq!(h.name(), "HighLowVolumeNodes");
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut h = HighLowVolumeNodes::new(4, 8).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
|
||||
let out = h.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvn_at_heavy_price() {
|
||||
// Most bars cluster at ~100 (heavy volume); one bar pokes up to 120 lightly.
|
||||
let mut h = HighLowVolumeNodes::new(6, 24).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
|
||||
candles.push(c(121.0, 119.0, 100.0));
|
||||
let out = h.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
// HVN should sit near the heavy 100 cluster, well below the light 120 poke.
|
||||
assert!(
|
||||
out.hvn < 110.0,
|
||||
"HVN should be at the heavy cluster, got {}",
|
||||
out.hvn
|
||||
);
|
||||
assert!(out.lvn >= out.hvn - 1e9); // lvn is a valid level
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvn_at_or_above_low() {
|
||||
let mut h = HighLowVolumeNodes::new(10, 24).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.3).sin() * 5.0,
|
||||
90.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for o in h.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.hvn.is_finite() && o.lvn.is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HighLowVolumeNodes::new(4, 8).unwrap();
|
||||
h.batch(&[c(110.0, 90.0, 1_000.0); 6]);
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
assert_eq!(h.update(c(110.0, 90.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.25).sin() * 9.0,
|
||||
90.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = HighLowVolumeNodes::new(20, 24).unwrap().batch(&candles);
|
||||
let mut b = HighLowVolumeNodes::new(20, 24).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_handled() {
|
||||
// Zero high-low span dumps all volume into bin 0 and returns early.
|
||||
let mut h = HighLowVolumeNodes::new(2, 4).unwrap();
|
||||
h.update(c(50.0, 50.0, 10.0));
|
||||
assert!(h.update(c(50.0, 50.0, 10.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_window_falls_back() {
|
||||
// All-zero volume leaves no traded bin; the LVN falls back to the HVN.
|
||||
let mut h = HighLowVolumeNodes::new(2, 4).unwrap();
|
||||
h.update(c(60.0, 40.0, 0.0));
|
||||
let out = h.update(c(60.0, 40.0, 0.0)).unwrap();
|
||||
assert_eq!(out.hvn, out.lvn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Ehlers two-pole Highpass Filter — removes the trend, keeps the cycles.
|
||||
#![allow(clippy::doc_markdown)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ehlers' two-pole Highpass Filter — strips the low-frequency trend from a price
|
||||
/// series, leaving the higher-frequency cyclic and noise content.
|
||||
///
|
||||
/// From John Ehlers' *Cycle Analytics for Traders* (2013):
|
||||
///
|
||||
/// ```text
|
||||
/// a = 0.707 · 2π / period
|
||||
/// alpha1 = (cos(a) + sin(a) − 1) / cos(a)
|
||||
/// HP_t = (1 − alpha1/2)² · (price_t − 2·price_{t−1} + price_{t−2})
|
||||
/// + 2·(1 − alpha1)·HP_{t−1} − (1 − alpha1)²·HP_{t−2}
|
||||
/// ```
|
||||
///
|
||||
/// A highpass filter is the complement of a smoother: where a lowpass keeps the
|
||||
/// trend, the highpass keeps everything *faster* than the cutoff `period`. The
|
||||
/// two-pole design gives a steep roll-off so frequencies below the cutoff are
|
||||
/// firmly removed, detrending the series into a zero-mean wave. This differs from
|
||||
/// the [`Decycler`](crate::Decycler), which is `price − highpass` (the *trend* that
|
||||
/// remains); the highpass is the cyclic part that the decycler discards.
|
||||
///
|
||||
/// The recursion needs two prior prices and two prior outputs; until then it emits
|
||||
/// `0`, so `warmup_period` is `1`. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, HighpassFilter};
|
||||
///
|
||||
/// let mut indicator = HighpassFilter::new(48).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + f64::from(i) + (f64::from(i) * 0.5).sin() * 3.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HighpassFilter {
|
||||
period: usize,
|
||||
alpha1: f64,
|
||||
prev_price_1: Option<f64>,
|
||||
prev_price_2: Option<f64>,
|
||||
hp1: f64,
|
||||
hp2: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl HighpassFilter {
|
||||
/// Construct a two-pole highpass filter with the given cutoff `period`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
let a = 0.707 * 2.0 * PI / period as f64;
|
||||
let alpha1 = (a.cos() + a.sin() - 1.0) / a.cos();
|
||||
Ok(Self {
|
||||
period,
|
||||
alpha1,
|
||||
prev_price_1: None,
|
||||
prev_price_2: None,
|
||||
hp1: 0.0,
|
||||
hp2: 0.0,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured cutoff period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HighpassFilter {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
let hp = match (self.prev_price_1, self.prev_price_2) {
|
||||
(Some(p1), Some(p2)) => {
|
||||
let one_minus = 1.0 - self.alpha1;
|
||||
let half = 1.0 - self.alpha1 / 2.0;
|
||||
half * half * (price - 2.0 * p1 + p2) + 2.0 * one_minus * self.hp1
|
||||
- one_minus * one_minus * self.hp2
|
||||
}
|
||||
_ => 0.0,
|
||||
};
|
||||
self.prev_price_2 = self.prev_price_1;
|
||||
self.prev_price_1 = Some(price);
|
||||
self.hp2 = self.hp1;
|
||||
self.hp1 = hp;
|
||||
self.last = Some(hp);
|
||||
Some(hp)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_price_1 = None;
|
||||
self.prev_price_2 = None;
|
||||
self.hp1 = 0.0;
|
||||
self.hp2 = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HighpassFilter"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(HighpassFilter::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let hp = HighpassFilter::new(48).unwrap();
|
||||
assert_eq!(hp.period(), 48);
|
||||
assert_eq!(hp.warmup_period(), 1);
|
||||
assert_eq!(hp.name(), "HighpassFilter");
|
||||
assert!(!hp.is_ready());
|
||||
assert_eq!(hp.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bars_are_zero() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
assert_eq!(hp.update(100.0), Some(0.0));
|
||||
assert_eq!(hp.update(101.0), Some(0.0));
|
||||
assert!(hp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_stays_zero() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
for v in hp.batch(&[50.0; 200]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_trend_is_attenuated() {
|
||||
// A straight ramp is low-frequency -> the highpass should drive its
|
||||
// output small after warmup (the trend is removed).
|
||||
let mut hp = HighpassFilter::new(20).unwrap();
|
||||
let out: Vec<f64> = hp
|
||||
.batch(&(0..400).map(f64::from).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.skip(200)
|
||||
.collect();
|
||||
for v in out {
|
||||
assert!(v.abs() < 5.0, "trend should be attenuated, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
hp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = hp.value();
|
||||
assert_eq!(hp.update(f64::NAN), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut hp = HighpassFilter::new(48).unwrap();
|
||||
hp.batch(&(0..40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(hp.is_ready());
|
||||
hp.reset();
|
||||
assert!(!hp.is_ready());
|
||||
assert_eq!(hp.update(100.0), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + f64::from(i) + (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = HighpassFilter::new(48).unwrap().batch(&xs);
|
||||
let mut b = HighpassFilter::new(48).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Tick-imbalance bar builder (simplified López de Prado) — sample on cumulative signed order flow.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::BarBuilder;
|
||||
|
||||
/// One completed imbalance bar.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ImbalanceBar {
|
||||
/// Open of the first candle in the bar.
|
||||
pub open: f64,
|
||||
/// Highest high across the bar.
|
||||
pub high: f64,
|
||||
/// Lowest low across the bar.
|
||||
pub low: f64,
|
||||
/// Close of the candle that closed the bar.
|
||||
pub close: f64,
|
||||
/// Signed cumulative tick imbalance at the close (`Σ sign`).
|
||||
pub imbalance: f64,
|
||||
/// `+1` if buy-side imbalance closed the bar, `-1` if sell-side.
|
||||
pub direction: i8,
|
||||
}
|
||||
|
||||
/// Tick-imbalance bar builder — a **simplified** form of López de Prado's
|
||||
/// imbalance bars.
|
||||
///
|
||||
/// Each candle is assigned a tick sign by the tick rule: `+1` if its close is above
|
||||
/// the previous close, `-1` if below, and the previous sign is carried on an
|
||||
/// unchanged close. The signed imbalance `θ = Σ sign` accumulates until its absolute
|
||||
/// value reaches a fixed `threshold`, at which point a bar closes. Imbalance bars
|
||||
/// therefore sample the market when order flow becomes *one-sided* — a burst of
|
||||
/// persistent buying or selling — rather than on time, count, or volume. This makes
|
||||
/// them sensitive to informed, directional trading.
|
||||
///
|
||||
/// **Simplification.** The full method estimates a *dynamic* threshold
|
||||
/// `E[T] · |2P − 1|` from an EWMA of the expected bar length `E[T]` and the buy-tick
|
||||
/// probability `P`, and can weight each sign by volume (volume-imbalance bars) or
|
||||
/// traded value (dollar-imbalance bars). This builder uses a **fixed** threshold on
|
||||
/// the unweighted tick imbalance. For the adaptive estimator and the volume/dollar
|
||||
/// variants, see López de Prado (2018), ch. 2.
|
||||
///
|
||||
/// At most one bar closes per candle, so [`BarBuilder::update`] returns either an
|
||||
/// empty vector or a single [`ImbalanceBar`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{BarBuilder, Candle, ImbalanceBars};
|
||||
///
|
||||
/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
|
||||
/// let mut bars = ImbalanceBars::new(3.0).unwrap();
|
||||
/// bars.update(flat(10.0)); // seed, no sign
|
||||
/// bars.update(flat(11.0)); // +1
|
||||
/// bars.update(flat(12.0)); // +2
|
||||
/// let out = bars.update(flat(13.0)); // +3 -> close
|
||||
/// assert_eq!(out.len(), 1);
|
||||
/// assert_eq!(out[0].direction, 1);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImbalanceBars {
|
||||
threshold: f64,
|
||||
count: usize,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
prev_close: Option<f64>,
|
||||
last_sign: i8,
|
||||
theta: f64,
|
||||
}
|
||||
|
||||
impl ImbalanceBars {
|
||||
/// Construct an imbalance-bar builder with the given absolute imbalance threshold.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `threshold` is not finite and positive.
|
||||
pub fn new(threshold: f64) -> Result<Self> {
|
||||
if !threshold.is_finite() || threshold <= 0.0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "threshold must be finite and positive",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
threshold,
|
||||
count: 0,
|
||||
open: 0.0,
|
||||
high: 0.0,
|
||||
low: 0.0,
|
||||
close: 0.0,
|
||||
prev_close: None,
|
||||
last_sign: 0,
|
||||
theta: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured absolute imbalance threshold.
|
||||
pub const fn threshold(&self) -> f64 {
|
||||
self.threshold
|
||||
}
|
||||
|
||||
/// Signed imbalance accumulated into the in-progress bar.
|
||||
pub const fn imbalance(&self) -> f64 {
|
||||
self.theta
|
||||
}
|
||||
}
|
||||
|
||||
impl BarBuilder for ImbalanceBars {
|
||||
type Bar = ImbalanceBar;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Vec<ImbalanceBar> {
|
||||
if self.count == 0 {
|
||||
self.open = candle.open;
|
||||
self.high = candle.high;
|
||||
self.low = candle.low;
|
||||
} else {
|
||||
self.high = self.high.max(candle.high);
|
||||
self.low = self.low.min(candle.low);
|
||||
}
|
||||
self.close = candle.close;
|
||||
self.count += 1;
|
||||
if let Some(prev) = self.prev_close {
|
||||
let sign = if candle.close > prev {
|
||||
1
|
||||
} else if candle.close < prev {
|
||||
-1
|
||||
} else {
|
||||
self.last_sign
|
||||
};
|
||||
self.last_sign = sign;
|
||||
self.theta += f64::from(sign);
|
||||
}
|
||||
self.prev_close = Some(candle.close);
|
||||
if self.theta.abs() < self.threshold {
|
||||
return Vec::new();
|
||||
}
|
||||
let direction = if self.theta > 0.0 { 1 } else { -1 };
|
||||
let bar = ImbalanceBar {
|
||||
open: self.open,
|
||||
high: self.high,
|
||||
low: self.low,
|
||||
close: self.close,
|
||||
imbalance: self.theta,
|
||||
direction,
|
||||
};
|
||||
self.count = 0;
|
||||
self.theta = 0.0;
|
||||
vec![bar]
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.count = 0;
|
||||
self.prev_close = None;
|
||||
self.last_sign = 0;
|
||||
self.theta = 0.0;
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ImbalanceBars"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn flat(price: f64) -> Candle {
|
||||
Candle::new(price, price, price, price, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_threshold() {
|
||||
assert!(matches!(
|
||||
ImbalanceBars::new(0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
ImbalanceBars::new(-3.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
ImbalanceBars::new(f64::NAN),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bars = ImbalanceBars::new(10.0).unwrap();
|
||||
assert_relative_eq!(bars.threshold(), 10.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(bars.imbalance(), 0.0, epsilon = 1e-12);
|
||||
assert_eq!(bars.name(), "ImbalanceBars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_imbalance_closes_up_bar() {
|
||||
let mut bars = ImbalanceBars::new(3.0).unwrap();
|
||||
bars.update(flat(10.0)); // seed
|
||||
bars.update(flat(11.0)); // +1
|
||||
bars.update(flat(12.0)); // +2
|
||||
let out = bars.update(flat(13.0)); // +3
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].direction, 1);
|
||||
assert_relative_eq!(out[0].imbalance, 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sell_imbalance_closes_down_bar() {
|
||||
let mut bars = ImbalanceBars::new(3.0).unwrap();
|
||||
bars.update(flat(10.0));
|
||||
bars.update(flat(9.0)); // -1
|
||||
bars.update(flat(8.0)); // -2
|
||||
let out = bars.update(flat(7.0)); // -3
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].direction, -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_tick_carries_previous_sign() {
|
||||
let mut bars = ImbalanceBars::new(3.0).unwrap();
|
||||
bars.update(flat(10.0));
|
||||
bars.update(flat(11.0)); // +1
|
||||
bars.update(flat(11.0)); // flat -> carries +1 -> +2
|
||||
assert_relative_eq!(bars.imbalance(), 2.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oscillation_does_not_reach_threshold() {
|
||||
let mut bars = ImbalanceBars::new(3.0).unwrap();
|
||||
bars.update(flat(10.0));
|
||||
bars.update(flat(11.0)); // +1
|
||||
bars.update(flat(10.0)); // -1 -> theta 0
|
||||
assert!(bars.update(flat(11.0)).is_empty()); // +1
|
||||
assert_relative_eq!(bars.imbalance(), 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bars = ImbalanceBars::new(3.0).unwrap();
|
||||
bars.update(flat(10.0));
|
||||
bars.update(flat(11.0));
|
||||
bars.reset();
|
||||
assert_relative_eq!(bars.imbalance(), 0.0, epsilon = 1e-12);
|
||||
// After reset the next candle re-seeds (no previous close).
|
||||
assert!(bars.update(flat(50.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_concatenates_completed_bars() {
|
||||
let mut bars = ImbalanceBars::new(2.0).unwrap();
|
||||
let candles = [
|
||||
flat(10.0),
|
||||
flat(11.0), // +1
|
||||
flat(12.0), // +2 -> close
|
||||
flat(13.0), // +1
|
||||
flat(14.0), // +2 -> close
|
||||
];
|
||||
let out = bars.batch(&candles);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(out.iter().all(|b| b.direction == 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Intraday Intensity Index (Bostian) — a cumulative volume-weighted close-location line.
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Intraday Intensity Index — David Bostian's cumulative line that weights each
|
||||
/// bar's volume by where the close lands inside the bar's range.
|
||||
///
|
||||
/// ```text
|
||||
/// II_t = volume * (2*close − high − low) / (high − low) (0 if high == low)
|
||||
/// III_t = III_{t−1} + II_t
|
||||
/// ```
|
||||
///
|
||||
/// The fraction `(2*close − high − low) / (high − low)` is `+1` when the bar
|
||||
/// closes on its high, `−1` when it closes on its low, and `0` at the midpoint.
|
||||
/// Scaling it by volume and accumulating produces a running measure of how
|
||||
/// aggressively the close is being pushed toward the extremes — Bostian's proxy
|
||||
/// for institutional accumulation (rising line) or distribution (falling line).
|
||||
///
|
||||
/// This is the **cumulative** Intraday Intensity (the original index), not the
|
||||
/// normalized "Intraday Intensity %" — the latter divides a windowed sum of `II`
|
||||
/// by a windowed sum of volume and is mathematically identical to
|
||||
/// [`Cmf`](crate::Cmf), so it is not duplicated here. The level of this line is
|
||||
/// arbitrary; only its slope and divergences against price matter. A doji whose
|
||||
/// `high == low` contributes nothing. Each `update` is O(1) and the first bar
|
||||
/// already emits a value.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, IntradayIntensity};
|
||||
///
|
||||
/// let mut indicator = IntradayIntensity::new();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.9, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct IntradayIntensity {
|
||||
iii: f64,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl IntradayIntensity {
|
||||
/// Construct a new Intraday Intensity Index. The line is parameter-free.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for IntradayIntensity {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let range = candle.high - candle.low;
|
||||
let ii = if range > 0.0 {
|
||||
candle.volume * (2.0 * candle.close - candle.high - candle.low) / range
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.iii += ii;
|
||||
self.last = Some(self.iii);
|
||||
Some(self.iii)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.iii = 0.0;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"IntradayIntensity"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(low, high, low, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let iii = IntradayIntensity::new();
|
||||
assert_eq!(iii.warmup_period(), 1);
|
||||
assert_eq!(iii.name(), "IntradayIntensity");
|
||||
assert!(!iii.is_ready());
|
||||
assert_eq!(iii.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_emits() {
|
||||
// close at the high: (2*101 - 102 - 100)/(2) = 0/... wait, high=102 low=100 close=101 -> 0.
|
||||
let mut iii = IntradayIntensity::new();
|
||||
// close on the high -> +1 * volume.
|
||||
let v = iii.update(candle(102.0, 100.0, 102.0, 500.0)).unwrap();
|
||||
assert_relative_eq!(v, 500.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_on_high_adds_full_volume() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, 1_000.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_on_low_subtracts_full_volume() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(110.0, 100.0, 100.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, -1_000.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_at_midpoint_adds_nothing() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(110.0, 100.0, 105.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_range_adds_nothing() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
let v = iii.update(candle(100.0, 100.0, 100.0, 1_000.0)).unwrap();
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulates_across_bars() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
iii.update(candle(110.0, 100.0, 110.0, 1_000.0)); // +1000
|
||||
let v = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap(); // -400 -> 600
|
||||
assert_relative_eq!(v, 600.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut iii = IntradayIntensity::new();
|
||||
iii.batch(&[
|
||||
candle(110.0, 100.0, 108.0, 1.0),
|
||||
candle(110.0, 100.0, 102.0, 1.0),
|
||||
]);
|
||||
assert!(iii.is_ready());
|
||||
iii.reset();
|
||||
assert!(!iii.is_ready());
|
||||
assert_eq!(iii.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 6.0;
|
||||
candle(base + 2.0, base - 2.0, base + 0.7, 1_000.0 + f64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let batch = IntradayIntensity::new().batch(&candles);
|
||||
let mut b = IntradayIntensity::new();
|
||||
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Jarque-Bera — a normality-test statistic on a rolling window.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Jarque-Bera — the Jarque-Bera test statistic measuring how far a window's
|
||||
/// distribution departs from normal, via its **skewness** and **excess
|
||||
/// kurtosis**.
|
||||
///
|
||||
/// ```text
|
||||
/// S = skewness = m3 / m2^(3/2)
|
||||
/// K = excess kurtosis = m4 / m2² − 3
|
||||
/// JB = (period / 6) · ( S² + K²/4 )
|
||||
/// ```
|
||||
///
|
||||
/// where `m2`, `m3`, `m4` are the second, third and fourth central moments of the
|
||||
/// window. A perfectly normal sample has zero skew and zero excess kurtosis, so
|
||||
/// `JB = 0`; the statistic grows as the distribution becomes asymmetric (non-zero
|
||||
/// skew) or fat- or thin-tailed (non-zero excess kurtosis). Under the null of
|
||||
/// normality `JB` is asymptotically χ² with two degrees of freedom, so values
|
||||
/// above roughly `6` reject normality at the 95% level — a useful streaming flag
|
||||
/// for fat-tail / crash-risk regimes in a return series.
|
||||
///
|
||||
/// The statistic is `≥ 0`. A degenerate window with zero variance (`m2 == 0`)
|
||||
/// returns `0`. The first value lands after `period` inputs; each `update`
|
||||
/// recomputes the four moments over the window in O(`period`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, JarqueBera};
|
||||
///
|
||||
/// let mut indicator = JarqueBera::new(50).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update((f64::from(i) * 0.3).sin());
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JarqueBera {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl JarqueBera {
|
||||
/// Construct a rolling Jarque-Bera over `period` values.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` and
|
||||
/// [`Error::InvalidPeriod`] if `period < 4` (the statistic is degenerate on
|
||||
/// fewer than four points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period < 4 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Jarque-Bera needs period >= 4",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let n = self.period as f64;
|
||||
let mean = self.window.iter().sum::<f64>() / n;
|
||||
let mut m2 = 0.0;
|
||||
let mut m3 = 0.0;
|
||||
let mut m4 = 0.0;
|
||||
for &v in &self.window {
|
||||
let d = v - mean;
|
||||
let d2 = d * d;
|
||||
m2 += d2;
|
||||
m3 += d2 * d;
|
||||
m4 += d2 * d2;
|
||||
}
|
||||
m2 /= n;
|
||||
m3 /= n;
|
||||
m4 /= n;
|
||||
if m2 == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let skew = m3 / m2.powf(1.5);
|
||||
let excess_kurt = m4 / (m2 * m2) - 3.0;
|
||||
(n / 6.0) * (skew * skew + excess_kurt * excess_kurt / 4.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for JarqueBera {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"JarqueBera"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(JarqueBera::new(0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
JarqueBera::new(3),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(JarqueBera::new(4).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let jb = JarqueBera::new(50).unwrap();
|
||||
assert_eq!(jb.period(), 50);
|
||||
assert_eq!(jb.warmup_period(), 50);
|
||||
assert_eq!(jb.name(), "JarqueBera");
|
||||
assert!(!jb.is_ready());
|
||||
assert_eq!(jb.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut jb = JarqueBera::new(4).unwrap();
|
||||
let out = jb.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_window_is_zero() {
|
||||
let mut jb = JarqueBera::new(8).unwrap();
|
||||
let last = jb.batch(&[5.0; 12]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_non_negative() {
|
||||
let mut jb = JarqueBera::new(30).unwrap();
|
||||
for v in jb
|
||||
.batch(
|
||||
&(0..200)
|
||||
.map(|i| (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!(v >= 0.0, "JB must be non-negative, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skewed_window_exceeds_symmetric() {
|
||||
// A symmetric window vs. one with a heavy outlier (high skew + kurtosis).
|
||||
let symmetric: Vec<f64> = vec![-3.0, -1.0, 0.0, 1.0, 3.0, -2.0, 2.0, 0.0];
|
||||
let skewed: Vec<f64> = vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 20.0];
|
||||
let jb_sym = JarqueBera::new(8)
|
||||
.unwrap()
|
||||
.batch(&symmetric)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
let jb_skew = JarqueBera::new(8)
|
||||
.unwrap()
|
||||
.batch(&skewed)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(
|
||||
jb_skew > jb_sym,
|
||||
"skewed ({jb_skew}) should exceed symmetric ({jb_sym})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut jb = JarqueBera::new(4).unwrap();
|
||||
let ready = jb
|
||||
.batch(&[1.0, 2.0, 3.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(jb.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut jb = JarqueBera::new(4).unwrap();
|
||||
jb.batch(&[1.0, 2.0, 3.0, 5.0]);
|
||||
assert!(jb.is_ready());
|
||||
jb.reset();
|
||||
assert!(!jb.is_ready());
|
||||
assert_eq!(jb.value(), None);
|
||||
assert_eq!(jb.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let xs: Vec<f64> = (0..120)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 9.0)
|
||||
.collect();
|
||||
let batch = JarqueBera::new(30).unwrap().batch(&xs);
|
||||
let mut b = JarqueBera::new(30).unwrap();
|
||||
let streamed: Vec<_> = xs.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! K-Ratio (Kestner) — slope of the cumulative-return curve over the standard error of that slope.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// K-Ratio over a trailing window of `period` returns.
|
||||
///
|
||||
/// Lars Kestner's K-Ratio measures the *consistency* of an equity curve, not just
|
||||
/// its return. It builds the cumulative-return curve over the window, fits an
|
||||
/// ordinary-least-squares trend line through it against time, and divides the
|
||||
/// fitted slope by the standard error of that slope:
|
||||
///
|
||||
/// ```text
|
||||
/// equity_t = Σ_{i<=t} return_i (cumulative curve, t = 1..period)
|
||||
/// slope, intercept = OLS(equity_t ~ t)
|
||||
/// SE(slope) = sqrt( (Σ residual² / (period − 2)) / Σ(t − t̄)² )
|
||||
/// K-Ratio = slope / SE(slope)
|
||||
/// ```
|
||||
///
|
||||
/// A high K-Ratio means the equity curve climbs *steadily* — a steep slope with
|
||||
/// little scatter around the trend. A strategy that earns the same total return in
|
||||
/// a few lucky jumps scores lower because its residual scatter inflates the
|
||||
/// standard error. This is the original 1996 form; later Kestner revisions scale by
|
||||
/// the number of periods (`slope / (SE · period)` in 2003, `slope / (SE · √period)`
|
||||
/// in 2013) — apply that scaling downstream if you need to compare across window
|
||||
/// lengths.
|
||||
///
|
||||
/// A perfectly straight window (e.g. constant returns) has zero residual scatter,
|
||||
/// so the slope's standard error is zero and the K-Ratio is undefined; the
|
||||
/// indicator reports `0.0` in that degenerate case. The statistic therefore needs
|
||||
/// some dispersion in the returns to be meaningful.
|
||||
///
|
||||
/// The first value lands after `period` returns; each `update` re-fits the line
|
||||
/// over the window (O(period)), which is O(1) in the length of the overall series.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, KRatio};
|
||||
///
|
||||
/// let mut indicator = KRatio::new(30).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// last = indicator.update(0.001 + (f64::from(i) * 0.3).sin() * 0.01);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl KRatio {
|
||||
/// Construct a K-Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 3` (the slope's standard error
|
||||
/// divides by `period − 2`).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 3 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "k-ratio needs period >= 3",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let count = self.window.len();
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let length = count as f64;
|
||||
// Build the cumulative-equity curve and its mean.
|
||||
let mut equity = 0.0;
|
||||
let mut curve: Vec<f64> = Vec::with_capacity(count);
|
||||
let mut sum_equity = 0.0;
|
||||
for ret in &self.window {
|
||||
equity += *ret;
|
||||
curve.push(equity);
|
||||
sum_equity += equity;
|
||||
}
|
||||
// Times are 1..=count, so Σt = count(count+1)/2 in closed form.
|
||||
let mean_time = f64::midpoint(length, 1.0);
|
||||
let mean_equity = sum_equity / length;
|
||||
let mut sxx = 0.0;
|
||||
let mut sxy = 0.0;
|
||||
for (index, value) in curve.iter().enumerate() {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let time = (index + 1) as f64;
|
||||
let dt = time - mean_time;
|
||||
sxx += dt * dt;
|
||||
sxy += dt * (value - mean_equity);
|
||||
}
|
||||
// sxx > 0 for count >= 2 (distinct integer times), guaranteed by period >= 3.
|
||||
let slope = sxy / sxx;
|
||||
let intercept = mean_equity - slope * mean_time;
|
||||
let mut sse = 0.0;
|
||||
for (index, value) in curve.iter().enumerate() {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let time = (index + 1) as f64;
|
||||
let residual = value - (intercept + slope * time);
|
||||
sse += residual * residual;
|
||||
}
|
||||
if sse <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let se_slope = (sse / (length - 2.0) / sxx).sqrt();
|
||||
slope / se_slope
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for KRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"KRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_three() {
|
||||
assert!(matches!(KRatio::new(2), Err(Error::InvalidPeriod { .. })));
|
||||
assert!(matches!(KRatio::new(0), Err(Error::InvalidPeriod { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let kr = KRatio::new(30).unwrap();
|
||||
assert_eq!(kr.period(), 30);
|
||||
assert_eq!(kr.warmup_period(), 30);
|
||||
assert_eq!(kr.name(), "KRatio");
|
||||
assert!(!kr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns [0.01, 0.02, 0.03] -> equity curve [0.01, 0.03, 0.06].
|
||||
// slope = 0.025, SE(slope) = sqrt((1/60000)/1/2) = 1/sqrt(120000).
|
||||
// K-Ratio = 0.025 * sqrt(120000) = 5*sqrt(3) ≈ 8.660254.
|
||||
let mut kr = KRatio::new(3).unwrap();
|
||||
let out = kr.batch(&[0.01, 0.02, 0.03]);
|
||||
let expected = 0.025_f64 / (1.0_f64 / 120_000.0).sqrt();
|
||||
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_returns_are_degenerate_zero() {
|
||||
// A perfectly linear equity curve has zero residual scatter -> undefined.
|
||||
let mut kr = KRatio::new(4).unwrap();
|
||||
let last = kr.batch(&[0.01; 4]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rising_curve_is_positive() {
|
||||
let mut kr = KRatio::new(5).unwrap();
|
||||
let last = kr
|
||||
.batch(&[0.01, 0.012, 0.009, 0.011, 0.013])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut kr = KRatio::new(3).unwrap();
|
||||
assert_eq!(kr.update(0.01), None);
|
||||
assert_eq!(kr.update(f64::NAN), None);
|
||||
assert_eq!(kr.update(0.02), None);
|
||||
assert!(kr.update(0.03).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut kr = KRatio::new(3).unwrap();
|
||||
kr.batch(&[0.01, 0.02, 0.03]);
|
||||
assert!(kr.is_ready());
|
||||
kr.reset();
|
||||
assert!(!kr.is_ready());
|
||||
assert_eq!(kr.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60)
|
||||
.map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.01)
|
||||
.collect();
|
||||
let batch = KRatio::new(20).unwrap().batch(&rets);
|
||||
let mut streamer = KRatio::new(20).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Kendall's tau-b — rank correlation by concordant vs. discordant pairs.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// `+1` / `0` / `-1` sign of `a − b`.
|
||||
fn sign(a: f64, b: f64) -> i32 {
|
||||
if a > b {
|
||||
1
|
||||
} else if a < b {
|
||||
-1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Kendall's tau-b — a rank correlation between two synchronised series based on
|
||||
/// the balance of **concordant** and **discordant** pairs, with a tie correction.
|
||||
///
|
||||
/// ```text
|
||||
/// over all pairs (i < j) in the window:
|
||||
/// concordant if (x_j − x_i) and (y_j − y_i) share a sign
|
||||
/// discordant if they have opposite signs
|
||||
/// tie_x / tie_y if the respective difference is zero
|
||||
/// n0 = N(N−1)/2
|
||||
/// tau_b = (n_concordant − n_discordant) / sqrt((n0 − tie_x)(n0 − tie_y))
|
||||
/// ```
|
||||
///
|
||||
/// Where [`PearsonCorrelation`](crate::PearsonCorrelation) measures *linear*
|
||||
/// co-movement and [`SpearmanCorrelation`](crate::SpearmanCorrelation) correlates
|
||||
/// ranks via their differences, Kendall's tau counts how often the two series move
|
||||
/// the **same direction** between every pair of observations. It is the most
|
||||
/// robust of the three to outliers and to non-linear-but-monotonic
|
||||
/// relationships, and the tau-b form corrects for ties so repeated values do not
|
||||
/// bias it. The output is in `[−1, +1]`: `+1` perfectly concordant, `−1`
|
||||
/// perfectly discordant, `0` no monotonic association.
|
||||
///
|
||||
/// The window holds the last `period` pairs and is recomputed each bar in
|
||||
/// O(`period²`). A window with no untied pairs on one side returns `0`. The first
|
||||
/// value lands after `period` inputs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, KendallTau};
|
||||
///
|
||||
/// let mut indicator = KendallTau::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let x = f64::from(i);
|
||||
/// last = indicator.update((x, 2.0 * x)); // perfectly concordant
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KendallTau {
|
||||
period: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl KendallTau {
|
||||
/// Construct a rolling Kendall's tau-b over `period` pairs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a correlation needs at
|
||||
/// least two pairs).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "Kendall tau needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of pairs.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let pairs: Vec<(f64, f64)> = self.window.iter().copied().collect();
|
||||
let len = pairs.len();
|
||||
let mut concordant: i64 = 0;
|
||||
let mut discordant: i64 = 0;
|
||||
let mut tie_x: i64 = 0;
|
||||
let mut tie_y: i64 = 0;
|
||||
for i in 0..len {
|
||||
for j in (i + 1)..len {
|
||||
let sx = sign(pairs[j].0, pairs[i].0);
|
||||
let sy = sign(pairs[j].1, pairs[i].1);
|
||||
if sx == 0 {
|
||||
tie_x += 1;
|
||||
}
|
||||
if sy == 0 {
|
||||
tie_y += 1;
|
||||
}
|
||||
let prod = sx * sy;
|
||||
if prod > 0 {
|
||||
concordant += 1;
|
||||
} else if prod < 0 {
|
||||
discordant += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let n0 = (len * (len - 1) / 2) as f64;
|
||||
let denom = ((n0 - tie_x as f64) * (n0 - tie_y as f64)).sqrt();
|
||||
if denom == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
((concordant - discordant) as f64 / denom).clamp(-1.0, 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for KendallTau {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"KendallTau"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(matches!(
|
||||
KendallTau::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(KendallTau::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let k = KendallTau::new(20).unwrap();
|
||||
assert_eq!(k.period(), 20);
|
||||
assert_eq!(k.warmup_period(), 20);
|
||||
assert_eq!(k.name(), "KendallTau");
|
||||
assert!(!k.is_ready());
|
||||
assert_eq!(k.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut k = KendallTau::new(4).unwrap();
|
||||
let out = k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0), (5.0, 5.0)]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotone_increasing_is_one() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|i| (f64::from(i), 2.0 * f64::from(i) + 1.0))
|
||||
.collect();
|
||||
let last = KendallTau::new(10)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotone_decreasing_is_minus_one() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|i| (f64::from(i), -3.0 * f64::from(i)))
|
||||
.collect();
|
||||
let last = KendallTau::new(10)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_channel_yields_zero() {
|
||||
// y constant -> every y-difference is a tie -> denom 0 -> 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
|
||||
let last = KendallTau::new(8)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let pairs: Vec<(f64, f64)> = (0..80)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
|
||||
})
|
||||
.collect();
|
||||
for v in KendallTau::new(20)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!((-1.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut k = KendallTau::new(4).unwrap();
|
||||
k.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 3.0), (4.0, 4.0)]);
|
||||
assert!(k.is_ready());
|
||||
k.reset();
|
||||
assert!(!k.is_ready());
|
||||
assert_eq!(k.value(), None);
|
||||
assert_eq!(k.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(t.sin(), (t * 0.5).cos())
|
||||
})
|
||||
.collect();
|
||||
let batch = KendallTau::new(14).unwrap().batch(&pairs);
|
||||
let mut b = KendallTau::new(14).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| b.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_are_corrected() {
|
||||
// Tied x values (points 0 and 1) and tied y values (points 1 and 2)
|
||||
// exercise the tie_x / tie_y correction counters.
|
||||
let mut k = KendallTau::new(4).unwrap();
|
||||
assert_eq!(k.update((1.0, 1.0)), None);
|
||||
assert_eq!(k.update((1.0, 2.0)), None);
|
||||
assert_eq!(k.update((2.0, 2.0)), None);
|
||||
let v = k.update((3.0, 3.0)).unwrap();
|
||||
assert!((-1.0..=1.0).contains(&v), "got {v}");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user