Compare commits

...

22 Commits

Author SHA1 Message Date
kingchenc d0061c73b8 release: bump 0.7.6 -> 0.7.7 (#229)
Bumps the workspace to 0.7.7 to ship the Go binding.
2026-06-09 17:35:25 +02:00
kingchenc 23d636fd97 Add the Go binding over the C ABI hub (#228)
Adds a Go binding (`bindings/go`) over the C ABI hub — the second language stecker after C#.

## What's here
- **`bindings/go`** — a cgo binding exposing all 514 indicators as idiomatic Go types with `New<Indicator>` constructors and `Update`/`Batch`/`Reset`/`Close` methods. The wrappers in `indicators_gen.go` are generated from `bindings/c/include/wickra.h` (same archetype taxonomy as the C# generator: scalar/batch, multi-output, bars, profile, profile-values, array-input). Opaque handles are freed by `Close()` with a `runtime.SetFinalizer` backstop; pointer arguments are caller-owned, panics never cross the boundary.
- **`examples/go`** — the full example suite mirroring C/C#: streaming, backtest, multi_timeframe, parallel_assets (goroutine fan-out), three strategies, and `fetch_btcusdt`/`live_binance`.
- **CI** — a `go` job builds the C ABI library, stages it, and runs `gofmt`/`go vet`/`go test` plus the offline examples on Linux, macOS and Windows.
- **Docs** — Go added to the README languages table, project layout, building/testing, CONTRIBUTING binding table + regenerate note, ARCHITECTURE, examples index, issue/PR templates, the About-description template, and the other binding READMEs.

## Linking / distribution
The binding links the prebuilt C ABI library via cgo (`libwickra.so`/`.dylib`/`wickra.dll` staged under `bindings/go/lib`, gitignored). The native libraries are already shipped per target triple by the existing `c-abi-build` release job; distribution is via the subdirectory module tag `bindings/go/vX.Y.Z` (gated), so `release.yml` needs no new publish job.

No Rust crate or `Cargo.toml` change — the Go module is standalone and additive.

Not for merge yet (gated, per request).
2026-06-09 17:33:37 +02:00
kingchenc fce26cf881 release: bump 0.7.5 -> 0.7.6 (#227)
Bumps the workspace to 0.7.6 to ship the C# (.NET) binding to NuGet.
2026-06-09 14:34:18 +02:00
kingchenc 91f6f67257 Add C# (.NET) binding over the C ABI hub (#226)
The first language stecker on the C ABI hub: a .NET binding exposing all 514
indicators as idiomatic `IDisposable` classes, generated from `wickra.h`.

## What's here

- **`bindings/csharp/`** — the `Wickra` .NET 8 package. `[LibraryImport]`
  source-generated P/Invoke (`NativeMethods.g.cs`) plus idiomatic wrappers
  (`Indicators.g.cs`), both generated from the committed `bindings/c/include/wickra.h`.
  The binding owns no indicator maths — it only marshals types across the C ABI.
- **Marshalling, verified end-to-end against the native library.** Opaque handles
  cross as `nint` kept alive per call via a `SafeHandle`; `bool` as
  `[MarshalAs(U1)]` (Rust `bool` is one byte); a self-correcting
  `DllImportResolver` validates the loaded library actually exports the Wickra
  ABI. Tests cover one representative per FFI archetype (scalar, candle, pairwise,
  multi-output, bars, profile, values-profile, order-book / array-input) plus
  exact Sma reference values.
- **NuGet packaging** — `dotnet pack` produces `Wickra.<version>.nupkg`; the
  release pipeline stages prebuilt native libraries under `runtimes/<rid>/native/`
  for six target triples (win/linux/osx × x64/arm64).
- **`examples/csharp/`** — nine examples mirroring `examples/c/`: streaming,
  backtest, multi_timeframe, parallel_assets, three strategies, and
  fetch_btcusdt + live_binance.
- **CI** — a `csharp` job on the three OSes builds the C ABI, tests the binding,
  and runs the offline examples. **Release** — a gated `csharp-publish` job packs
  and pushes to NuGet (gated on `NUGET_API_KEY`, independent of the GitHub-release
  job so a C# hiccup never blocks the C/C++ asset release).
- **Docs consistency wave** — README, CONTRIBUTING, CHANGELOG, examples/README,
  the issue / PR templates, `sync-about.yml`, and `.gitattributes`.

The native Python / Node / WASM bindings and the C ABI are untouched; this is
additive. Publishing to NuGet stays gated behind the release tag and the secret.
2026-06-09 14:32:05 +02:00
kingchenc 4caaa1db97 release: bump 0.7.4 -> 0.7.5 (#225)
Version bump for the C ABI hub release (0.7.4 -> 0.7.5). See #222 + #224.
2026-06-09 02:26:27 +02:00
kingchenc 12681e4b1b C ABI: full example suite + docs & About coverage (#224)
Stacked on #222 (base `feat/c-abi-hub`), so the diff is just the additions on top of the hub foundation — no merge of #222 required.

## What this adds

**Examples — full parity with rust/python/node (`examples/c/`)**
- `streaming.c` upgraded to the multi-indicator (SMA/EMA/RSI/MACD + signals) demo
- `backtest.c`, `multi_timeframe.c` (manual time-bucket resampling), `parallel_assets.c` (serial vs OpenMP fan-out, one handle per asset)
- three educational strategies: `strategy_rsi_mean_reversion.c`, `strategy_macd_adx.c`, `strategy_bollinger_squeeze.c`
- two network examples shelling out to `curl`: `fetch_btcusdt.c`, `live_binance.c` (REST poll)
- two header-only helpers (`wickra_csv.h`, `wickra_strategy.h`) since the C ABI ships no IO layer
- CMake builds all 11; the 9 offline ones run under `ctest` on 3 OS; the network two are built-only

**Docs & metadata — surface the C ABI everywhere it was missing**
- ARCHITECTURE diagram + crate table, SECURITY + THREAT_MODEL (the C ABI as the sole `unsafe` FFI surface), the three binding package READMEs, issue/PR templates, CHANGELOG, and the GitHub About template (live About + org description updated too)

**Cleanup**
- removed all references to the private generator tooling from public files (`bindings/c/src/lib.rs` header, `CONTRIBUTING.md`, `sync-about.yml`)

Verified locally: `cargo build -p wickra-c --release`, `cmake + ctest` (9/9 pass), and `-Wall -Wextra -Wpedantic` clean on gcc 13.
2026-06-09 02:14:28 +02:00
kingchenc 91e05e3c26 C ABI hub crate (bindings/c) foundation (#222)
## What

Introduces `wickra-c` — a `cdylib` + `staticlib` that exposes the Rust core over a **C ABI**. This is the hub every C-capable language (C, C++, Go, C#, Java, R) links against, instead of re-wiring each indicator natively. The native Python/Node/WASM bindings are untouched; this is purely additive, for ecosystems without first-class Rust tooling.

## Scope (foundation slice)

This PR deliberately validates the **whole pipeline end to end with one indicator (SMA)** before scaling to all 514, so the CI / cross-OS / header-drift mechanics are proven green first.

- Opaque `*mut T` handles; `wickra_<ind>_{new,update,batch,reset,free}`.
- NaN sentinel for warmup / NULL handles; caller-owned batch buffers; every function NULL-safe.
- cbindgen generates and commits `bindings/c/include/wickra.h` with opaque handle typedefs.
- A C smoke example (`examples/c/`) links the header + compiled library and runs (CMake + ctest).
- A `c-abi` CI job builds the library and runs the smoke test on **Linux, macOS and Windows**, plus a header drift check on Linux.

## Notes

- The per-indicator FFI blocks are plain `#[no_mangle]` functions, **not** a macro: cbindgen cannot see macro-generated functions on stable Rust (macro expansion needs nightly), so the blocks are written literally and will be generated mechanically by the ScriptHelpers `capi` wrapper in a follow-up (same model as the committed-but-generated Node `index.js`).
- `bindings/c` cannot inherit the workspace `forbid(unsafe_code)` lint (the C boundary needs raw pointers), so it mirrors every workspace lint and only relaxes `unsafe_code`. The Rust core stays `unsafe`-forbidden.

## Follow-ups (separate PRs)

- ScriptHelpers `capi` generator + wire the scalar family (~235).
- Hand-written blocks for multi-output / custom-input / bars (~279).
- Docs consistency wave (README / docs / webpage: Python·Node·WASM·Rust → +C).
- Release wiring (native-lib matrix + header/lib GH-release assets) — gated.
2026-06-09 02:07:03 +02:00
kingchenc 9d0983b666 ci(sync-about): sync indicator count into the webpage About page (#223)
The `wickra.org/about` page carries the indicator count in the bot-syncable `<N> indicators` token, but `sync-about.yml` only rewrote `index.md` + `.vitepress/config.ts` on the webpage. Add `about.md` to the webpage count step's `sed` file list and its `git add`, so the About page's count self-heals on every push-to-main / tag like the rest of the marketing site.

No-op for the Rust build — workflow file only.
2026-06-08 21:38:42 +02:00
kingchenc 13c8250488 release: bump 0.7.3 -> 0.7.4 (#221)
Version bump **0.7.3 → 0.7.4** for the B19 Alt-Chart Bars batch (7 new bar builders, 507 → 514).

Bumps `Cargo.toml` (+ `wickra-core` dep), `Cargo.lock`, `pyproject.toml`, the Node `package.json` and its six platform packages, both `package-lock.json` files, and the CHANGELOG (`[Unreleased]` → `[0.7.4]` with compare URLs).
2026-06-08 14:33:43 +02:00
kingchenc e5305ffa94 feat: add 7 alt-chart bar builders (B19) (#220)
Adds seven information-driven bar builders to the **Alt-Chart Bars** family, the final batch of the family-deepening run. Indicator count **507 → 514**.

## Builders
All implement the `BarBuilder` trait (`update(Candle) -> Vec<Bar>`), emitting a data-dependent number of completed bars per candle.

| Builder | Driver | Bar fields |
|---------|--------|-----------|
| `RangeBars` | close | open, close, direction |
| `TickBars` | OHLCV | open, high, low, close, volume |
| `VolumeBars` | OHLCV | open, high, low, close, volume |
| `DollarBars` (Lopez de Prado) | OHLCV | + dollar |
| `ImbalanceBars` | OHLC | + imbalance, direction |
| `RunBars` | OHLC | + length, direction |
| `ThreeLineBreakBars` | close | open, close, direction |

## Touchpoints
Seven core modules (each with full unit tests), `mod.rs`/`lib.rs` (builders counted, bar element types on their own re-export lines), README family rows, Python/Node/WASM hand-written bindings for the variable-length output (Python tuples + `(k, N)` ndarray; Node `Vec<object>`; WASM array of objects), the `bar_builder_update_candle` fuzz target, dedicated Python + Node tests, the `BAR_BUILDERS` completeness exclusion, and CHANGELOG.

## Verification
- `cargo test -p wickra-core --lib` — 4207 passed
- `cargo test -p wickra-core --doc` — 464 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean
- `npm test` (node) — 584 passed
- `pytest` (python) — 957 passed
2026-06-08 14:32:40 +02:00
kingchenc 46be7a54ea release: bump 0.7.2 -> 0.7.3 (#219)
Version bump **0.7.2 → 0.7.3** for the B18 Risk / Performance batch (9 new indicators, 498 → 507).

Bumps `Cargo.toml` (+ `wickra-core` dep), `Cargo.lock`, `pyproject.toml`, the Node `package.json` and its six platform packages, both `package-lock.json` files, and the CHANGELOG (`[Unreleased]` → `[0.7.3]` with compare URLs).
2026-06-08 13:29:39 +02:00
kingchenc bca61322b5 feat: add 9 Risk / Performance indicators (B18) (#218)
Adds nine risk/performance metrics to the existing **Risk / Performance** family, all consuming a per-period return series (`f64` in, `f64` out). Indicator count **498 → 507**.

## Indicators

Single-param (`new(period)`, macro bindings):
- **SterlingRatio** — mean return over average drawdown of the equity curve.
- **BurkeRatio** — return over root-sum-squared drawdowns.
- **MartinRatio** — Ulcer Performance Index; return over RMS percentage drawdown.
- **TailRatio** — 95th percentile over the absolute 5th percentile return.
- **KRatio** — Kestner; equity-curve OLS slope over the standard error of that slope.
- **CommonSenseRatio** — tail ratio times gain-to-pain.
- **GainToPainRatio** — sum of returns over the sum of absolute losses.

Multi-param (hand-written Python/Node bindings, variadic WASM macro):
- **UpsidePotentialRatio** — `new(period, mar)`; upside mean over downside deviation (Sortino philosophy).
- **M2Measure** — `new(period, risk_free, benchmark_stddev)`; Modigliani M², Sharpe rescaled into benchmark return units.

## Touchpoints
Core modules + unit tests, `mod.rs`/`lib.rs` wiring, Python/Node/WASM bindings (`index.d.ts`/`index.js` regenerated), fuzz drive lines, Python `SCALAR` registry + Node factories, CHANGELOG, and the indicator counters.

## Verification
- `cargo test -p wickra-core --lib` — 4149 passed
- `cargo test -p wickra-core --doc` — 457 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean
- `npm test` (node) — 577 passed
- `pytest` (python) — 947 passed
2026-06-08 13:23:01 +02:00
kingchenc fc6f619550 release: bump 0.7.1 -> 0.7.2 (#217)
Version bump 0.7.1 -> 0.7.2 for the B17 Market Profile batch (498 indicators).
2026-06-08 04:18:34 +02:00
kingchenc 91aa6fffbf feat(market-profile): naked POC, single prints, profile shape, HVN/LVN, composite profile (B17) (#216)
## B17 Market Profile — five new indicators (493 → 498)

| Indicator | Output | Notes |
|-----------|--------|-------|
| `NakedPoc` | `f64` | most recent untouched point-of-control level |
| `SinglePrints` | `f64` | count of single-print price levels |
| `ProfileShape` | `f64` | b/P/D shape classification as a numeric code |
| `HighLowVolumeNodes` | struct `{hvn, lvn}` | highest/lowest volume nodes |
| `CompositeProfile` | struct `{poc, vah, val}` | multi-session composite volume profile |

### Wiring
- Core structs + full unit tests; all join the existing **Market Profile** family.
- Hand-written Python/Node/WASM bindings (f64 via candle helpers; struct via PyArray2 / `#[napi(object)]` / `Object`+`Reflect::set`).
- Fuzz drives in `indicator_update_candle.rs`; CANDLE_SCALAR + MULTI registry tests + reference tests.
- README counter + `docs/README.md` + `FAMILIES` assert bumped to 498.

### Verify (local, all green)
- `cargo test -p wickra-core --lib`: 4066 · `--doc`: 448
- clippy workspace: clean
- node: 568 · pytest: 938
2026-06-08 04:17:06 +02:00
kingchenc 5862401958 release: bump 0.7.0 -> 0.7.1 (#215)
Version bump 0.7.0 -> 0.7.1 for the B16 Derivatives batch (493 indicators).
2026-06-08 03:35:31 +02:00
kingchenc ff5a047078 feat(derivatives): leverage, OI/volume, perpetual premium, funding APR, OI momentum (B16) (#214)
## B16 Derivatives — five new indicators (488 → 493)

All consume a `DerivativesTick` and emit `f64`:

| Indicator | Reads | Formula |
|-----------|-------|---------|
| `EstimatedLeverageRatio` | open_interest, long_size, short_size | `OI / (long + short)` |
| `OiToVolumeRatio` | open_interest, taker_buy_volume, taker_sell_volume | `OI / (buy + sell)` |
| `PerpetualPremiumIndex` | mark_price, index_price | `(mark − index) / index` |
| `FundingImpliedApr` | funding_rate | `rate × intervals_per_year` |
| `OpenInterestMomentum` | open_interest | `100 · (OI_t − OI_{t−period}) / OI_{t−period}` |

### Wiring
- Core structs + full unit tests (incl. zero-denominator branches).
- Hand-written Python/Node/WASM tick bindings; two new tick helpers (`deriv_oi_long_short`, `deriv_oi_taker`).
- Fuzz drives in `indicator_update_derivatives.rs`; dedicated reference + streaming-vs-batch tests (Python + Node).
- README counter + `docs/README.md` + `FAMILIES` assert bumped to 493.

### Verify (local, all green)
- `cargo test -p wickra-core --lib`: 4028 · `--doc`: 443
- clippy workspace: clean
- node: 563 · pytest: 928
2026-06-08 03:33:59 +02:00
kingchenc dc415a77fd release: bump 0.6.9 -> 0.7.0 (#213)
Version bump 0.6.9 -> 0.7.0 for the B15 Microstructure batch (488 indicators).
2026-06-08 03:10:16 +02:00
kingchenc e385734275 feat(microstructure): trade-sign autocorrelation, PIN, Hasbrouck information share (B15) (#212)
## B15 Microstructure — three new indicators (485 → 488)

| Indicator | Input | Output | Notes |
|-----------|-------|--------|-------|
| `TradeSignAutocorrelation` | `Trade` | `f64` ∈ [-1,1] | lag-1 autocorrelation of the signed aggressor (order-flow persistence) |
| `Pin` | `Trade` | `f64` ∈ [0,1] | probability of informed trading from rolling buy/sell imbalance (EKOP single-window estimator); `name()` = `"PIN"` |
| `HasbrouckInformationShare` | `(f64, f64)` | `f64` ∈ [0,1] | variance-ratio proxy for each venue's share of price discovery |

### Wiring
- Core structs + full unit tests (every branch).
- Hand-written Python/Node/WASM bindings for the two `Trade`-input indicators (precedent `TradeImbalance`); `node_pair_indicator!` / `wasm_pair_indicator!` macro bindings + hand Python pyclass for the pairwise Hasbrouck (precedent `RollingCorrelation`).
- Fuzz drives added to `indicator_update_trade.rs` and `indicator_update_pair.rs`.
- Dedicated Python + Node streaming-vs-batch and reference tests; Hasbrouck in the `PAIR` registry.
- README counter (3 spots) + `docs/README.md` + `FAMILIES` assert bumped to 488.

### Verify (all green, local)
- `cargo test -p wickra-core --lib`: 3991 passed
- `cargo test -p wickra-core --doc`: 438 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: clean
- node: 561 passed · pytest: 926 passed
2026-06-08 03:07:50 +02:00
kingchenc 3a46b210bb release: bump 0.6.8 -> 0.6.9 (#211)
Release 0.6.9 — ships the B14 Candlestick Patterns indicators (485 total). Version-string bump only.
2026-06-08 02:30:46 +02:00
kingchenc 943825d6a0 feat: add Candlestick Patterns deepening (B14, 6 indicators) (#209)
B14 of the family-deepening roadmap — six candlestick patterns (479 -> 485), all in the **Candlestick Patterns** family.

**Fixed-lookback (candle-pattern macro bindings, neutral 0.0 during warmup):**
- **Tristar** — three-doji star reversal.
- **Harami Cross** — Harami whose second candle is a contained doji.
- **Tower Top/Bottom** — tall bar, small pause, tall opposite bar.

**Windowed / parameterized (hand-bound, `candle -> f64`):**
- **Frying Pan Bottom** — rounded U-shaped accumulation base, recovery-confirmed.
- **Dumpling Top** — rounded dome-shaped distribution top, breakdown-confirmed.
- **New Price Lines** — run of N consecutive new closing highs (+1) / lows (-1).

Window/Gap (Rising-Falling) dropped (SKIP — existing gap coverage). Wiring complete across core, Python, Node, WASM, fuzz, tests, README + docs counter (485) and CHANGELOG. Verified: core 3966 + doc 435, clippy clean, node 560, python 922.
2026-06-08 02:29:24 +02:00
kingchenc d16df1e224 ci: warm the cargo registry with backoff so a DNS blip can't fail clippy (#210)
## Problem

A macOS runner on #206 failed the **Rust** job's clippy step with:

```
Updating crates.io index
error: failed to get `rayon` as a dependency ...
  download of config.json failed
  [6] Couldn't resolve host name (Could not resolve host: index.crates.io)
```

A pure transient DNS blip — unrelated to the change (a docs-only PR). `CARGO_NET_RETRY=10` only does fast in-process retries; a longer DNS outage outlasts them, so the very first cargo step's crates.io index fetch fails the whole job.

## Fix

Add a **Warm cargo registry** step right after the cache restore in the `rust`, `clippy-bindings` and `msrv` jobs. It runs `cargo fetch` in a 5-attempt loop with real backoff (20/40/60/80s sleeps) so the dependency graph is pulled once, patiently, riding out a multi-second DNS outage that cargo's rapid retries can't. The later clippy/build/test steps then resolve from the warmed local cache.

Mirrors the existing inline retry pattern already used for setup-node/setup-python CDN flakes. Can be extended to the coverage/python/wasm/node jobs if they ever hit the same blip.
2026-06-08 02:22:26 +02:00
kingchenc 34c097aee2 docs: refresh Python benchmark figures from a fresh measured run (#206)
The published Python benchmark tables (README/BENCHMARKS.md) were a stale, incoherent run. Re-measured locally with the current build (wickra 0.6.5, post batch fast-paths) via `compare_libraries.py` on the same 9950X.

- **Streaming vs talipp:** 11-56x (was 9-58x).
- **Batch:** real per-indicator numbers; MACD and ATR were notably off in the old table.
- **Prose:** Wickra beats TA-Lib on RSI and ATR (no longer MACD, which now trails 130 vs 111 us).

Rust tables unchanged. Numbers are a single coherent run; absolute us still depend on machine state (caveat already in the doc).
2026-06-08 02:13:13 +02:00
165 changed files with 142607 additions and 239 deletions
+15
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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`
+1 -1
View File
@@ -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
+246
View File
@@ -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
+142 -2
View File
@@ -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
+8 -8
View File
@@ -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
View File
@@ -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
+18 -18
View File
@@ -26,15 +26,15 @@ was built to expose.
| Indicator | **★&nbsp;Wickra** | talipp | TA-Lib (recompute) |
|------------------|------------------:|------------------|-----------------------|
| SMA(20) | **0.063 µs ★** | 0.59 µs (9×) | 204 µs (3 300×) |
| EMA(20) | **0.060 µs ★** | 0.72 µs (12×) | 212 µs (3 500×) |
| RSI(14) | **0.065 µs ★** | 1.06 µs (16×) | 230 µs (3 600×) |
| MACD(12, 26, 9) | **0.078 µs ★** | 4.22 µs (54×) | 245 µs (3 100×) |
| Bollinger(20, 2) | **0.088 µs ★** | 5.15 µs (58×) | 229 µs (2 600×) |
| 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 **958× faster**;
against the recompute-on-every-tick libraries it is **2 60014 000× faster**
(`finta` RSI hits 14 000×). tulipy / pandas-ta land in the same recompute band
Against the only other incremental Python peer Wickra is **1156× faster**;
against the recompute-on-every-tick libraries it is **2 80019 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):
@@ -63,17 +63,17 @@ field everywhere.
**Python** (20 000-bar pass, µs/op, lower = faster):
| Indicator | Wickra | TA-Lib | tulipy | pandas-ta |
|------------------|---------:|-------:|-------:|----------:|
| SMA(20) | 22.7 | **15.4** | 15.9 | 33.7 |
| EMA(20) | 30.8 | **30.3** | 31.1 | 48.8 |
| RSI(14) | 58.9 | 72.5 | **38.5** | 94.8 |
| MACD(12, 26, 9) | 71.7 | 99.1 | **33.5** | 207.6 |
| Bollinger(20, 2) | 84.9 | 65.7 | **32.3** | 336.4 |
| ATR(14) | 52.0 | 79.4 | **31.9** | — |
| 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 TA-Lib on RSI, MACD and ATR and the whole Python field on every
row; tulipy's SIMD C stays ahead on the heavier indicators.
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:
+84 -1
View File
@@ -7,6 +7,80 @@ 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`).
@@ -1369,7 +1443,16 @@ 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.8...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
+9 -1
View File
@@ -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
View File
@@ -1944,7 +1944,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.6.8"
version = "0.7.7"
dependencies = [
"approx",
"criterion",
@@ -1955,7 +1955,7 @@ dependencies = [
[[package]]
name = "wickra-bench"
version = "0.6.8"
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.8"
version = "0.7.7"
dependencies = [
"approx",
"proptest",
@@ -1977,7 +1984,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.6.8"
version = "0.7.7"
dependencies = [
"approx",
"csv",
@@ -1994,7 +2001,7 @@ dependencies = [
[[package]]
name = "wickra-examples"
version = "0.6.8"
version = "0.7.7"
dependencies = [
"serde_json",
"tokio",
@@ -2004,7 +2011,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.6.8"
version = "0.7.7"
dependencies = [
"napi",
"napi-build",
@@ -2014,7 +2021,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.6.8"
version = "0.7.7"
dependencies = [
"numpy",
"pyo3",
@@ -2023,7 +2030,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.6.8"
version = "0.7.7"
dependencies = [
"console_error_panic_hook",
"js-sys",
+3 -2
View File
@@ -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.8"
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.8" }
wickra-core = { path = "crates/wickra-core", version = "0.7.7" }
thiserror = "2"
rayon = "1.10"
+70 -38
View File
@@ -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=479" 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>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![CodeQL](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![GitHub release](https://img.shields.io/github/v/release/wickra-lib/wickra?logo=github&color=green)](https://github.com/wickra-lib/wickra/releases/latest)
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](#license)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/wickra-lib/wickra/badge)](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13094/badge)](https://www.bestpractices.dev/projects/13094)
[![Build provenance](https://img.shields.io/badge/provenance-attested-brightgreen?logo=github)](https://github.com/wickra-lib/wickra/attestations)
[![Docs](https://img.shields.io/badge/docs-docs.wickra.org-0ea5e9?logo=readthedocs&logoColor=white)](https://docs.wickra.org)
[![CI](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/ci.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![CodeQL](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/codeql.svg)](https://github.com/wickra-lib/wickra/actions/workflows/codeql.yml)
[![codecov](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/codecov.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![GitHub release](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/release.svg)](https://github.com/wickra-lib/wickra/releases/latest)
[![crates.io](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/crates.svg)](https://crates.io/crates/wickra)
[![PyPI](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/pypi.svg)](https://pypi.org/project/wickra/)
[![npm](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/npm.svg)](https://www.npmjs.com/package/wickra)
[![NuGet](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/nuget.svg)](https://www.nuget.org/packages/Wickra)
[![License: MIT OR Apache-2.0](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/license.svg)](#license)
[![OpenSSF Scorecard](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/scorecard.svg)](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
[![OpenSSF Best Practices](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/best-practices.svg)](https://www.bestpractices.dev/projects/13094)
[![Build provenance](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/provenance.svg)](https://github.com/wickra-lib/wickra/attestations)
[![Docs](https://raw.githubusercontent.com/wickra-lib/.github/main/profile/badges/docs.svg)](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 479 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),
@@ -66,19 +71,20 @@ an afterthought — **live, tick-by-tick data** — without giving up the breadt
a full batch library, and without making you reimplement your indicators four
times to get there.
- **The biggest streaming-native catalogue, period.** 479 indicators across 24
- **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, four first-class targets.** Native **Python · Node.js ·
WebAssembly · Rust** — identical math, identical results, zero per-language
reimplementation and zero GIL bottleneck.
- **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 479 indicators**.
- **Orders of magnitude faster where it counts.** In streaming Wickra is **958×**
for all 514 indicators**.
- **Orders of magnitude faster where it counts.** In streaming Wickra is **1156×**
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
@@ -95,7 +101,8 @@ Every other library forces one of those compromises. Wickra doesn't:
| Library | Install | Streaming | Languages | Indicators | Active |
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
| **★&nbsp;Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **479** | **yes** |
| **★&nbsp;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 |
@@ -118,7 +125,7 @@ useful version of that itch is the one other people can build on too.
## Benchmarks
Wickra updates every indicator in **O(1)** per tick. In **streaming** — the
workload it is built for — it is **958× faster** than the only other incremental
workload it is built for — it is **1156× 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`.
@@ -128,7 +135,7 @@ Full tables (Rust + Python, streaming + batch) and how to reproduce them live in
## Indicators
479 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).
@@ -148,14 +155,14 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| 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) |
| 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 |
| 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 |
@@ -166,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
@@ -177,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
@@ -237,20 +249,26 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 479 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
```
@@ -278,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
@@ -297,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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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. |
+46
View File
@@ -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 }
+80
View File
@@ -0,0 +1,80 @@
# Wickra — C / C++
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![GitHub release](https://img.shields.io/github/v/release/wickra-lib/wickra?logo=github&color=green)](https://github.com/wickra-lib/wickra/releases/latest)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](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.
+20
View File
@@ -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
+66
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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/
+78
View File
@@ -0,0 +1,78 @@
# Wickra — .NET
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![NuGet](https://img.shields.io/nuget/v/Wickra.svg?logo=nuget&color=blue)](https://www.nuget.org/packages/Wickra)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](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));
}
}
+51
View File
@@ -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
+57
View File
@@ -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>
+26
View File
@@ -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;
}
}
+87
View File
@@ -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/&lt;rid&gt;/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";
}
}
+100
View File
@@ -0,0 +1,100 @@
# Wickra — Go
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![Go Reference](https://pkg.go.dev/badge/github.com/wickra-lib/wickra/bindings/go.svg)](https://pkg.go.dev/github.com/wickra-lib/wickra/bindings/go)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](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.
+3
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
# Prebuilt Wickra C ABI libraries are provisioned locally / in CI, not committed.
*.so
*.dylib
*.dll
*.a
*.lib
*.exp
+25
View File
@@ -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")
+151
View File
@@ -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
}
+5 -3
View File
@@ -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
+12 -1
View File
@@ -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
+151 -1
View File
@@ -28,6 +28,15 @@ 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),
@@ -386,6 +395,15 @@ const candleScalar = {
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)) {
@@ -491,6 +509,8 @@ const multi = {
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)) {
@@ -661,6 +681,7 @@ const pairFactories = {
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)) {
@@ -1264,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);
@@ -1273,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', () => {
@@ -1426,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.
@@ -1688,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);
});
+369
View File
@@ -409,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
@@ -459,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
@@ -1167,6 +1224,87 @@ export declare class UNIVERSALOSC {
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)
@@ -1449,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)`
@@ -3421,6 +3572,33 @@ export declare class CandleVolume {
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)
@@ -3430,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)
@@ -4198,6 +4421,33 @@ export declare class TDTrap {
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()
@@ -4279,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)
@@ -4459,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()
@@ -4763,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)
+36 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.6.8",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.6.8",
"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.8",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.6.8",
"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.8",
"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.8",
"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": [
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.6.8",
"version": "0.7.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.6.8",
"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.8",
"wickra-darwin-x64": "0.6.8",
"wickra-linux-arm64-gnu": "0.6.8",
"wickra-linux-x64-gnu": "0.6.8",
"wickra-win32-arm64-msvc": "0.6.8",
"wickra-win32-x64-msvc": "0.6.8"
"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.8",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.8.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.8",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.8.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.8",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.8.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.8",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.8.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.8",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.8.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.8",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.8.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"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.6.8",
"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.8",
"wickra-linux-arm64-gnu": "0.6.8",
"wickra-darwin-x64": "0.6.8",
"wickra-darwin-arm64": "0.6.8",
"wickra-win32-x64-msvc": "0.6.8",
"wickra-win32-arm64-msvc": "0.6.8"
"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
+5 -3
View File
@@ -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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.6.8"
version = "0.7.7"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
+70
View File
@@ -25,6 +25,15 @@ from __future__ import annotations
from ._wickra import (
__version__,
M2Measure,
UpsidePotentialRatio,
GainToPainRatio,
CommonSenseRatio,
KRatio,
TailRatio,
MartinRatio,
BurkeRatio,
SterlingRatio,
AUTOCORRPGRAM,
EVENBETTERSINE,
BANDPASS,
@@ -350,16 +359,34 @@ from ._wickra import (
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,
@@ -458,6 +485,8 @@ from ._wickra import (
QuotedSpread,
DepthSlope,
# Microstructure: trade flow
Pin,
TradeSignAutocorrelation,
RollMeasure,
AmihudIlliquidity,
Vpin,
@@ -465,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,
@@ -533,6 +568,15 @@ from ._wickra import (
)
__all__ = [
"M2Measure",
"UpsidePotentialRatio",
"GainToPainRatio",
"CommonSenseRatio",
"KRatio",
"TailRatio",
"MartinRatio",
"BurkeRatio",
"SterlingRatio",
"AUTOCORRPGRAM",
"EVENBETTERSINE",
"BANDPASS",
@@ -859,16 +903,34 @@ __all__ = [
"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",
@@ -967,6 +1029,8 @@ __all__ = [
"QuotedSpread",
"DepthSlope",
# Microstructure: trade flow
"Pin",
"TradeSignAutocorrelation",
"RollMeasure",
"AmihudIlliquidity",
"Vpin",
@@ -974,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",
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,15 @@ 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)),
@@ -217,6 +226,7 @@ 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)),
@@ -382,6 +392,42 @@ 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),
@@ -984,6 +1030,16 @@ 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),
@@ -3278,6 +3334,54 @@ def test_equivolume_reference():
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 ------------------------------------------------------------
@@ -3618,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()
@@ -3629,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)
@@ -3999,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 ------------------------------------------------------
@@ -4038,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
+5 -3
View File
@@ -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
@@ -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,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,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);
}
}
@@ -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,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_{t1}, ry_t = y_t y_{t1} (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,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,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,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,232 @@
//! M² / ModiglianiModigliani measure — Sharpe expressed in benchmark return units.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// M² (ModiglianiModigliani) measure over a trailing window of `period` returns.
///
/// ```text
/// Sharpe = (mean(returns) risk_free) / stddev(returns)
/// M² = risk_free + Sharpe · benchmark_stddev
/// ```
///
/// The [`SharpeRatio`](crate::SharpeRatio) is dimensionless, which makes it hard to
/// communicate: "0.8" means little to a client. M² rescales the Sharpe ratio back
/// into *return units* by levering (or de-levering) the portfolio to the
/// benchmark's volatility. The result answers a concrete question: "if this
/// strategy had run at the market's risk level, what return would it have
/// produced?" Two portfolios can then be ranked on the same risk-adjusted scale,
/// and M² preserves the Sharpe ordering while being quoted as a percentage.
///
/// `stddev` is the sample standard deviation (Bessel's `n 1`).
/// `risk_free` is the per-period risk-free rate and `benchmark_stddev` the
/// per-period volatility of the benchmark, both supplied by the caller at the
/// return frequency. A flat window has zero volatility and the Sharpe ratio is
/// undefined; the indicator returns `0.0` in that case rather than producing `NaN`.
///
/// Each `update` is O(1) — running sums maintain `Σr` and `Σr²` as the window slides.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, M2Measure};
///
/// let mut indicator = M2Measure::new(20, 0.0, 0.02).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(0.001 + (f64::from(i) * 0.1).sin() * 0.01);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct M2Measure {
period: usize,
risk_free: f64,
benchmark_stddev: f64,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl M2Measure {
/// Construct an M² measure over `period` returns with the given per-period
/// risk-free rate and benchmark standard deviation.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or
/// [`Error::InvalidParameter`] if `risk_free` is not finite or
/// `benchmark_stddev` is negative or not finite.
pub fn new(period: usize, risk_free: f64, benchmark_stddev: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "m2 measure needs period >= 2",
});
}
if !risk_free.is_finite() || !benchmark_stddev.is_finite() || benchmark_stddev < 0.0 {
return Err(Error::InvalidParameter {
message: "risk_free must be finite and benchmark_stddev finite and non-negative",
});
}
Ok(Self {
period,
risk_free,
benchmark_stddev,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
})
}
/// Configured window of returns.
pub const fn period(&self) -> usize {
self.period
}
/// Configured per-period risk-free rate.
pub const fn risk_free(&self) -> f64 {
self.risk_free
}
/// Configured per-period benchmark standard deviation.
pub const fn benchmark_stddev(&self) -> f64 {
self.benchmark_stddev
}
}
impl Indicator for M2Measure {
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 {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(ret);
self.sum += ret;
self.sum_sq += ret * ret;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean = self.sum / n;
let var = (self.sum_sq - n * mean * mean).max(0.0) / (n - 1.0);
let sd = var.sqrt();
if sd == 0.0 {
return Some(0.0);
}
let sharpe = (mean - self.risk_free) / sd;
Some(self.risk_free + sharpe * self.benchmark_stddev)
}
fn reset(&mut self) {
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"M2Measure"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
M2Measure::new(1, 0.0, 0.02),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn rejects_invalid_benchmark_stddev() {
assert!(matches!(
M2Measure::new(10, 0.0, -0.01),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
M2Measure::new(10, f64::NAN, 0.02),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let m2 = M2Measure::new(20, 0.001, 0.02).unwrap();
assert_eq!(m2.period(), 20);
assert_relative_eq!(m2.risk_free(), 0.001, epsilon = 1e-12);
assert_relative_eq!(m2.benchmark_stddev(), 0.02, epsilon = 1e-12);
assert_eq!(m2.warmup_period(), 20);
assert_eq!(m2.name(), "M2Measure");
}
#[test]
fn reference_value() {
// returns [0.01, 0.02, 0.03, 0.04], rf = 0, benchmark_stddev = 0.02.
// mean = 0.025, sd = sqrt(0.000166666...), Sharpe = 0.025 / sd.
// M2 = 0 + Sharpe * 0.02.
let mut m2 = M2Measure::new(4, 0.0, 0.02).unwrap();
let out = m2.batch(&[0.01, 0.02, 0.03, 0.04]);
let sharpe = 0.025_f64 / (0.000_166_666_666_666_666_67_f64).sqrt();
assert_relative_eq!(out[3].unwrap(), sharpe * 0.02, epsilon = 1e-9);
}
#[test]
fn constant_returns_yield_zero() {
let mut m2 = M2Measure::new(5, 0.0, 0.02).unwrap();
for v in m2.batch(&[0.01; 10]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn ignores_non_finite_input() {
let mut m2 = M2Measure::new(3, 0.0, 0.02).unwrap();
assert_eq!(m2.update(0.01), None);
assert_eq!(m2.update(f64::NAN), None);
assert_eq!(m2.update(0.02), None);
assert!(m2.update(0.03).is_some());
}
#[test]
fn reset_clears_state() {
let mut m2 = M2Measure::new(3, 0.0, 0.02).unwrap();
m2.batch(&[0.01, 0.02, 0.03]);
assert!(m2.is_ready());
m2.reset();
assert!(!m2.is_ready());
assert_eq!(m2.update(0.01), None);
}
#[test]
fn batch_equals_streaming() {
let rets: Vec<f64> = (0..50)
.map(|i| 0.001 + (f64::from(i) * 0.2).sin() * 0.01)
.collect();
let batch = M2Measure::new(10, 0.0, 0.02).unwrap().batch(&rets);
let mut streamer = M2Measure::new(10, 0.0, 0.02).unwrap();
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,220 @@
//! Martin Ratio (Ulcer Performance Index) — mean return over the Ulcer Index.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Martin Ratio — also called the Ulcer Performance Index (UPI) — 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% = 100 · (peak_t equity_t) / peak_t (percentage drawdown)
/// UlcerIdx = sqrt( mean( dd_t%² ) )
/// Martin = mean(returns) / UlcerIdx
/// ```
///
/// The Martin Ratio divides the average per-period return by the **Ulcer Index** —
/// the root-mean-square of the *percentage* drawdowns. The Ulcer Index, by
/// construction, measures the depth *and* duration of the time spent under water:
/// a long shallow slump and a short deep one can score the same. Compared to
/// Wickra's other drawdown ratios, Martin uses the RMS (not the average as in the
/// [`SterlingRatio`](crate::SterlingRatio), nor the un-normalised sum-norm as in the
/// [`BurkeRatio`](crate::BurkeRatio)) and expresses drawdowns in **percent**, so its
/// denominator is on a `0..100` scale and its output is numerically smaller than
/// the fractional-drawdown ratios. A window that never draws down has an Ulcer Index
/// of zero 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, MartinRatio};
///
/// let mut indicator = MartinRatio::new(14).unwrap();
/// let mut last = None;
/// for i in 0..28 {
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MartinRatio {
period: usize,
window: VecDeque<f64>,
}
impl MartinRatio {
/// Construct a Martin 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: "martin 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_pct_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_pct = 100.0 * (peak - equity) / peak;
sum_drawdown_pct_sq += drawdown_pct * drawdown_pct;
}
let ulcer_index = (sum_drawdown_pct_sq / length).sqrt();
if ulcer_index > 0.0 {
(sum_return / length) / ulcer_index
} else {
0.0
}
}
}
impl Indicator for MartinRatio {
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 {
"MartinRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
MartinRatio::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let mr = MartinRatio::new(14).unwrap();
assert_eq!(mr.period(), 14);
assert_eq!(mr.warmup_period(), 14);
assert_eq!(mr.name(), "MartinRatio");
assert!(!mr.is_ready());
}
#[test]
fn reference_value() {
// returns [0.1, -0.1, 0.1]: drawdowns% = [0, 10, 1].
// Ulcer Index = sqrt((0 + 100 + 1)/3) = sqrt(101/3).
// Martin = (0.1/3) / sqrt(101/3).
let mut mr = MartinRatio::new(3).unwrap();
let out = mr.batch(&[0.1, -0.1, 0.1]);
let expected = (0.1_f64 / 3.0) / (101.0_f64 / 3.0).sqrt();
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn no_drawdown_is_zero() {
let mut mr = MartinRatio::new(3).unwrap();
let last = mr
.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 mr = MartinRatio::new(3).unwrap();
let last = mr
.batch(&[-0.05, -0.02, -0.03])
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last < 0.0);
}
#[test]
fn ignores_non_finite_input() {
let mut mr = MartinRatio::new(3).unwrap();
assert_eq!(mr.update(0.1), None);
assert_eq!(mr.update(f64::NAN), None);
assert_eq!(mr.update(-0.1), None);
assert!(mr.update(0.1).is_some());
}
#[test]
fn reset_clears_state() {
let mut mr = MartinRatio::new(3).unwrap();
mr.batch(&[0.1, -0.1, 0.1]);
assert!(mr.is_ready());
mr.reset();
assert!(!mr.is_ready());
assert_eq!(mr.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 = MartinRatio::new(14).unwrap().batch(&rets);
let mut streamer = MartinRatio::new(14).unwrap();
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
+111 -2
View File
@@ -63,6 +63,7 @@ mod bomar_bands;
mod breadth_thrust;
mod breakaway;
mod bullish_percent_index;
mod burke_ratio;
mod butterfly;
mod calendar_spread;
mod calmar_ratio;
@@ -84,6 +85,8 @@ mod cmf;
mod cmo;
mod coefficient_of_variation;
mod cointegration;
mod common_sense_ratio;
mod composite_profile;
mod concealing_baby_swallow;
mod conditional_value_at_risk;
mod connors_rsi;
@@ -109,6 +112,7 @@ mod disparity_index;
mod distance_ssd;
mod doji;
mod doji_star;
mod dollar_bars;
mod donchian;
mod donchian_stop;
mod double_bollinger;
@@ -117,6 +121,7 @@ mod downside_gap_three_methods;
mod dpo;
mod dragonfly_doji;
mod drawdown_duration;
mod dumpling_top;
mod dx;
mod dynamic_momentum_index;
mod ease_of_movement;
@@ -130,6 +135,7 @@ mod ema;
mod empirical_mode_decomposition;
mod engulfing;
mod equivolume;
mod estimated_leverage_ratio;
mod even_better_sinewave;
mod evening_doji_star;
mod evwma;
@@ -153,11 +159,14 @@ mod footprint;
mod force_index;
mod fractal_chaos_bands;
mod frama;
mod fry_pan_bottom;
mod funding_basis;
mod funding_implied_apr;
mod funding_rate;
mod funding_rate_mean;
mod funding_rate_zscore;
mod gain_loss_ratio;
mod gain_to_pain_ratio;
mod gap_side_by_side_white;
mod garch11;
mod garman_klass;
@@ -171,11 +180,14 @@ mod gravestone_doji;
mod hammer;
mod hanging_man;
mod harami;
mod harami_cross;
mod hasbrouck_information_share;
mod head_and_shoulders;
mod heikin_ashi;
mod heikin_ashi_oscillator;
mod high_low_index;
mod high_low_range;
mod high_low_volume_nodes;
mod high_wave;
mod highpass_filter;
mod hikkake;
@@ -193,6 +205,7 @@ mod hurst_channel;
mod hurst_exponent;
mod ichimoku;
mod identical_three_crows;
mod imbalance_bars;
mod in_neck;
mod inertia;
mod information_ratio;
@@ -206,6 +219,7 @@ mod inverted_hammer;
mod jarque_bera;
mod jma;
mod jump_indicator;
mod k_ratio;
mod kagi_bars;
mod kalman_hedge_ratio;
mod kama;
@@ -233,6 +247,7 @@ mod log_return;
mod long_legged_doji;
mod long_line;
mod long_short_ratio;
mod m2_measure;
mod ma_envelope;
mod macd;
mod macd_ext;
@@ -240,6 +255,7 @@ mod macd_fix;
mod macd_histogram;
mod mama;
mod market_facilitation_index;
mod martin_ratio;
mod marubozu;
mod mass_index;
mod mat_hold;
@@ -263,8 +279,10 @@ mod mom;
mod morning_doji_star;
mod morning_evening_star;
mod murrey_math_lines;
mod naked_poc;
mod natr;
mod new_highs_new_lows;
mod new_price_lines;
mod nrtr;
mod nvi;
mod ob_imbalance_full;
@@ -273,9 +291,11 @@ mod ob_imbalance_topn;
mod obv;
mod oi_delta;
mod oi_price_divergence;
mod oi_to_volume_ratio;
mod oi_weighted;
mod omega_ratio;
mod on_neck;
mod open_interest_momentum;
mod opening_marubozu;
mod opening_range;
mod order_flow_imbalance;
@@ -290,8 +310,10 @@ mod pearson_correlation;
mod percent_above_ma;
mod percent_b;
mod percentage_trailing_stop;
mod perpetual_premium_index;
mod pgo;
mod piercing_dark_cloud;
mod pin;
mod pivot_reversal;
mod plus_di;
mod plus_dm;
@@ -300,6 +322,7 @@ mod point_and_figure_bars;
mod polarized_fractal_efficiency;
mod ppo;
mod ppo_histogram;
mod profile_shape;
mod profit_factor;
mod projection_bands;
mod projection_oscillator;
@@ -310,6 +333,7 @@ mod qstick;
mod quartile_bands;
mod quoted_spread;
mod r_squared;
mod range_bars;
mod realized_spread;
mod realized_volatility;
mod recovery_factor;
@@ -337,6 +361,7 @@ mod rolling_quantile;
mod roofing_filter;
mod rsi;
mod rsx;
mod run_bars;
mod rvi;
mod rvi_volatility;
mod rwi;
@@ -355,6 +380,7 @@ mod short_line;
mod signed_volume;
mod sine_wave;
mod sine_weighted_ma;
mod single_prints;
mod skewness;
mod sma;
mod smi;
@@ -373,6 +399,7 @@ mod starc_bands;
mod stc;
mod std_dev;
mod step_trailing_stop;
mod sterling_ratio;
mod stick_sandwich;
mod stoch_rsi;
mod stochastic;
@@ -380,6 +407,7 @@ mod stochastic_cci;
mod super_smoother;
mod super_trend;
mod t3;
mod tail_ratio;
mod taker_buy_sell_ratio;
mod takuri;
mod tasuki_gap;
@@ -407,17 +435,21 @@ mod term_structure_basis;
mod three_drives;
mod three_inside;
mod three_line_break;
mod three_line_break_bars;
mod three_line_strike;
mod three_outside;
mod three_soldiers_or_crows;
mod three_stars_in_south;
mod thrusting;
mod tick_bars;
mod tick_index;
mod tii;
mod time_based_stop;
mod time_of_day_return_profile;
mod tower_top_bottom;
mod tpo_profile;
mod trade_imbalance;
mod trade_sign_autocorrelation;
mod trade_volume_index;
mod trend_label;
mod trend_strength_index;
@@ -427,6 +459,7 @@ mod triangle;
mod trima;
mod trin;
mod triple_top_bottom;
mod tristar;
mod trix;
mod true_range;
mod tsf;
@@ -447,6 +480,7 @@ mod universal_oscillator;
mod up_down_volume_ratio;
mod upside_gap_three_methods;
mod upside_gap_two_crows;
mod upside_potential_ratio;
mod value_area;
mod value_at_risk;
mod variance;
@@ -457,6 +491,7 @@ mod volatility_cone;
mod volatility_of_volatility;
mod volatility_ratio;
mod volty_stop;
mod volume_bars;
mod volume_by_time_profile;
mod volume_oscillator;
mod volume_profile;
@@ -542,6 +577,7 @@ pub use bomar_bands::{BomarBands, BomarBandsOutput};
pub use breadth_thrust::BreadthThrust;
pub use breakaway::Breakaway;
pub use bullish_percent_index::BullishPercentIndex;
pub use burke_ratio::BurkeRatio;
pub use butterfly::Butterfly;
pub use calendar_spread::CalendarSpread;
pub use calmar_ratio::CalmarRatio;
@@ -563,6 +599,8 @@ pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coefficient_of_variation::CoefficientOfVariation;
pub use cointegration::{Cointegration, CointegrationOutput};
pub use common_sense_ratio::CommonSenseRatio;
pub use composite_profile::{CompositeProfile, CompositeProfileOutput};
pub use concealing_baby_swallow::ConcealingBabySwallow;
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
@@ -588,6 +626,7 @@ pub use disparity_index::DisparityIndex;
pub use distance_ssd::DistanceSsd;
pub use doji::Doji;
pub use doji_star::DojiStar;
pub use dollar_bars::{DollarBar, DollarBars};
pub use donchian::{Donchian, DonchianOutput};
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
@@ -596,6 +635,7 @@ pub use downside_gap_three_methods::DownsideGapThreeMethods;
pub use dpo::Dpo;
pub use dragonfly_doji::DragonflyDoji;
pub use drawdown_duration::DrawdownDuration;
pub use dumpling_top::DumplingTop;
pub use dx::Dx;
pub use dynamic_momentum_index::DynamicMomentumIndex;
pub use ease_of_movement::EaseOfMovement;
@@ -609,6 +649,7 @@ pub use ema::Ema;
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use engulfing::Engulfing;
pub use equivolume::{Equivolume, EquivolumeOutput};
pub use estimated_leverage_ratio::EstimatedLeverageRatio;
pub use even_better_sinewave::EvenBetterSinewave;
pub use evening_doji_star::EveningDojiStar;
pub use evwma::Evwma;
@@ -632,11 +673,14 @@ pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
pub use force_index::ForceIndex;
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
pub use frama::Frama;
pub use fry_pan_bottom::FryPanBottom;
pub use funding_basis::FundingBasis;
pub use funding_implied_apr::FundingImpliedApr;
pub use funding_rate::FundingRate;
pub use funding_rate_mean::FundingRateMean;
pub use funding_rate_zscore::FundingRateZScore;
pub use gain_loss_ratio::GainLossRatio;
pub use gain_to_pain_ratio::GainToPainRatio;
pub use gap_side_by_side_white::GapSideBySideWhite;
pub use garch11::Garch11;
pub use garman_klass::GarmanKlassVolatility;
@@ -650,11 +694,14 @@ pub use gravestone_doji::GravestoneDoji;
pub use hammer::Hammer;
pub use hanging_man::HangingMan;
pub use harami::Harami;
pub use harami_cross::HaramiCross;
pub use hasbrouck_information_share::HasbrouckInformationShare;
pub use head_and_shoulders::HeadAndShoulders;
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
pub use heikin_ashi_oscillator::HeikinAshiOscillator;
pub use high_low_index::HighLowIndex;
pub use high_low_range::HighLowRange;
pub use high_low_volume_nodes::{HighLowVolumeNodes, HighLowVolumeNodesOutput};
pub use high_wave::HighWave;
pub use highpass_filter::HighpassFilter;
pub use hikkake::Hikkake;
@@ -672,6 +719,7 @@ pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use hurst_exponent::HurstExponent;
pub use ichimoku::{Ichimoku, IchimokuOutput};
pub use identical_three_crows::IdenticalThreeCrows;
pub use imbalance_bars::{ImbalanceBar, ImbalanceBars};
pub use in_neck::InNeck;
pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
@@ -685,6 +733,7 @@ pub use inverted_hammer::InvertedHammer;
pub use jarque_bera::JarqueBera;
pub use jma::Jma;
pub use jump_indicator::JumpIndicator;
pub use k_ratio::KRatio;
pub use kagi_bars::{KagiBar, KagiBars};
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
pub use kama::Kama;
@@ -712,6 +761,7 @@ pub use log_return::LogReturn;
pub use long_legged_doji::LongLeggedDoji;
pub use long_line::LongLine;
pub use long_short_ratio::LongShortRatio;
pub use m2_measure::M2Measure;
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
pub use macd::{MacdIndicator, MacdOutput};
pub use macd_ext::{MaType, MacdExt};
@@ -719,6 +769,7 @@ pub use macd_fix::MacdFix;
pub use macd_histogram::MacdHistogram;
pub use mama::{Mama, MamaOutput};
pub use market_facilitation_index::MarketFacilitationIndex;
pub use martin_ratio::MartinRatio;
pub use marubozu::Marubozu;
pub use mass_index::MassIndex;
pub use mat_hold::MatHold;
@@ -742,8 +793,10 @@ pub use mom::Mom;
pub use morning_doji_star::MorningDojiStar;
pub use morning_evening_star::MorningEveningStar;
pub use murrey_math_lines::{MurreyMathLines, MurreyMathLinesOutput};
pub use naked_poc::NakedPoc;
pub use natr::Natr;
pub use new_highs_new_lows::NewHighsNewLows;
pub use new_price_lines::NewPriceLines;
pub use nrtr::{Nrtr, NrtrOutput};
pub use nvi::Nvi;
pub use ob_imbalance_full::OrderBookImbalanceFull;
@@ -752,9 +805,11 @@ pub use ob_imbalance_topn::OrderBookImbalanceTopN;
pub use obv::Obv;
pub use oi_delta::OpenInterestDelta;
pub use oi_price_divergence::OIPriceDivergence;
pub use oi_to_volume_ratio::OiToVolumeRatio;
pub use oi_weighted::OIWeighted;
pub use omega_ratio::OmegaRatio;
pub use on_neck::OnNeck;
pub use open_interest_momentum::OpenInterestMomentum;
pub use opening_marubozu::OpeningMarubozu;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use order_flow_imbalance::OrderFlowImbalance;
@@ -769,8 +824,10 @@ pub use pearson_correlation::PearsonCorrelation;
pub use percent_above_ma::PercentAboveMa;
pub use percent_b::PercentB;
pub use percentage_trailing_stop::PercentageTrailingStop;
pub use perpetual_premium_index::PerpetualPremiumIndex;
pub use pgo::Pgo;
pub use piercing_dark_cloud::PiercingDarkCloud;
pub use pin::Pin;
pub use pivot_reversal::PivotReversal;
pub use plus_di::PlusDi;
pub use plus_dm::PlusDm;
@@ -779,6 +836,7 @@ pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
pub use polarized_fractal_efficiency::PolarizedFractalEfficiency;
pub use ppo::Ppo;
pub use ppo_histogram::PpoHistogram;
pub use profile_shape::ProfileShape;
pub use profit_factor::ProfitFactor;
pub use projection_bands::{ProjectionBands, ProjectionBandsOutput};
pub use projection_oscillator::ProjectionOscillator;
@@ -789,6 +847,7 @@ pub use qstick::Qstick;
pub use quartile_bands::{QuartileBands, QuartileBandsOutput};
pub use quoted_spread::QuotedSpread;
pub use r_squared::RSquared;
pub use range_bars::{RangeBar, RangeBars};
pub use realized_spread::RealizedSpread;
pub use realized_volatility::RealizedVolatility;
pub use recovery_factor::RecoveryFactor;
@@ -816,6 +875,7 @@ pub use rolling_quantile::RollingQuantile;
pub use roofing_filter::RoofingFilter;
pub use rsi::Rsi;
pub use rsx::Rsx;
pub use run_bars::{RunBar, RunBars};
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
@@ -834,6 +894,7 @@ pub use short_line::ShortLine;
pub use signed_volume::SignedVolume;
pub use sine_wave::SineWave;
pub use sine_weighted_ma::SineWeightedMa;
pub use single_prints::SinglePrints;
pub use skewness::Skewness;
pub use sma::Sma;
pub use smi::Smi;
@@ -852,6 +913,7 @@ pub use starc_bands::{StarcBands, StarcBandsOutput};
pub use stc::Stc;
pub use std_dev::StdDev;
pub use step_trailing_stop::StepTrailingStop;
pub use sterling_ratio::SterlingRatio;
pub use stick_sandwich::StickSandwich;
pub use stoch_rsi::StochRsi;
pub use stochastic::{Stochastic, StochasticOutput};
@@ -859,6 +921,7 @@ pub use stochastic_cci::StochasticCci;
pub use super_smoother::SuperSmoother;
pub use super_trend::{SuperTrend, SuperTrendOutput};
pub use t3::T3;
pub use tail_ratio::TailRatio;
pub use taker_buy_sell_ratio::TakerBuySellRatio;
pub use takuri::Takuri;
pub use tasuki_gap::TasukiGap;
@@ -886,17 +949,21 @@ pub use term_structure_basis::TermStructureBasis;
pub use three_drives::ThreeDrives;
pub use three_inside::ThreeInside;
pub use three_line_break::ThreeLineBreak;
pub use three_line_break_bars::{LineBreakBar, ThreeLineBreakBars};
pub use three_line_strike::ThreeLineStrike;
pub use three_outside::ThreeOutside;
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
pub use three_stars_in_south::ThreeStarsInSouth;
pub use thrusting::Thrusting;
pub use tick_bars::{TickBar, TickBars};
pub use tick_index::TickIndex;
pub use tii::Tii;
pub use time_based_stop::TimeBasedStop;
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
pub use tower_top_bottom::TowerTopBottom;
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use trade_sign_autocorrelation::TradeSignAutocorrelation;
pub use trade_volume_index::TradeVolumeIndex;
pub use trend_label::TrendLabel;
pub use trend_strength_index::TrendStrengthIndex;
@@ -906,6 +973,7 @@ pub use triangle::Triangle;
pub use trima::Trima;
pub use trin::Trin;
pub use triple_top_bottom::TripleTopBottom;
pub use tristar::Tristar;
pub use trix::Trix;
pub use true_range::TrueRange;
pub use tsf::Tsf;
@@ -926,6 +994,7 @@ pub use universal_oscillator::UniversalOscillator;
pub use up_down_volume_ratio::UpDownVolumeRatio;
pub use upside_gap_three_methods::UpsideGapThreeMethods;
pub use upside_gap_two_crows::UpsideGapTwoCrows;
pub use upside_potential_ratio::UpsidePotentialRatio;
pub use value_area::{ValueArea, ValueAreaOutput};
pub use value_at_risk::ValueAtRisk;
pub use variance::Variance;
@@ -936,6 +1005,7 @@ pub use volatility_cone::{VolatilityCone, VolatilityConeOutput};
pub use volatility_of_volatility::VolatilityOfVolatility;
pub use volatility_ratio::VolatilityRatio;
pub use volty_stop::VoltyStop;
pub use volume_bars::{VolumeBar, VolumeBars};
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
pub use volume_oscillator::VolumeOscillator;
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
@@ -1412,6 +1482,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TasukiGap",
"UniqueThreeRiver",
"ConcealingBabySwallow",
"Tristar",
"HaramiCross",
"TowerTopBottom",
"FryPanBottom",
"DumplingTop",
"NewPriceLines",
],
),
(
@@ -1434,6 +1510,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Vpin",
"AmihudIlliquidity",
"RollMeasure",
"TradeSignAutocorrelation",
"Pin",
"HasbrouckInformationShare",
],
),
(
@@ -1451,6 +1530,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"LiquidationFeatures",
"TermStructureBasis",
"CalendarSpread",
"EstimatedLeverageRatio",
"OiToVolumeRatio",
"PerpetualPremiumIndex",
"FundingImpliedApr",
"OpenInterestMomentum",
],
),
(
@@ -1461,6 +1545,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"OpeningRange",
"VolumeProfile",
"TpoProfile",
"NakedPoc",
"SinglePrints",
"ProfileShape",
"HighLowVolumeNodes",
"CompositeProfile",
],
),
(
@@ -1485,11 +1574,31 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Alpha",
"WinRate",
"Expectancy",
"SterlingRatio",
"BurkeRatio",
"MartinRatio",
"TailRatio",
"KRatio",
"CommonSenseRatio",
"GainToPainRatio",
"UpsidePotentialRatio",
"M2Measure",
],
),
(
"Alt-Chart Bars",
&["RenkoBars", "KagiBars", "PointAndFigureBars"],
&[
"RenkoBars",
"KagiBars",
"PointAndFigureBars",
"RangeBars",
"TickBars",
"VolumeBars",
"DollarBars",
"ImbalanceBars",
"RunBars",
"ThreeLineBreakBars",
],
),
(
"Market Breadth",
@@ -1597,6 +1706,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 479, "FAMILIES total drifted from indicator count");
assert_eq!(total, 514, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,318 @@
//! Naked POC — the nearest prior-session point of control price has not yet revisited.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Naked (Virgin) POC — the nearest **untested** point of control from a prior
/// session: a heavily-traded price the market has not traded back through since.
///
/// ```text
/// every `session_len` candles forms a session; its POC (heaviest-volume price) is
/// recorded as "naked"
/// a naked POC becomes "tested" once a later candle's high-low range covers it
/// output = the nearest still-naked POC to the current close (or the close itself
/// if every prior POC has been revisited)
/// ```
///
/// A point of control is a magnet — price tends to return to fair value. A *naked*
/// (or virgin) POC is one that has not yet been revisited, so it carries an
/// outstanding "pull": untested POCs are high-probability targets and
/// support/resistance on the approach. This indicator records each completed
/// session's POC, marks them tested as price trades through them, and reports the
/// closest one still outstanding.
///
/// The first value lands after `session_len` candles (the first session's POC).
/// Each `update` is O(`session_len · bins` + naked-count).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, NakedPoc};
///
/// let mut indicator = NakedPoc::new(20, 24).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + (f64::from(i) * 0.2).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 NakedPoc {
session_len: usize,
bins: usize,
session: VecDeque<Candle>,
naked: Vec<f64>,
last_close: f64,
ready: bool,
last: Option<f64>,
}
impl NakedPoc {
/// Construct a Naked POC tracker with the given `session_len` and profile
/// `bins`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `session_len` or `bins` is zero.
pub fn new(session_len: usize, bins: usize) -> Result<Self> {
if session_len == 0 || bins == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
session_len,
bins,
session: VecDeque::with_capacity(session_len),
naked: Vec::new(),
last_close: 0.0,
ready: false,
last: None,
})
}
/// Configured `(session_len, bins)`.
pub const fn params(&self) -> (usize, usize) {
(self.session_len, self.bins)
}
/// Number of currently-naked POCs.
pub fn naked_count(&self) -> usize {
self.naked.len()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn session_poc(&self) -> f64 {
let mut low = f64::INFINITY;
let mut high = f64::NEG_INFINITY;
for c in &self.session {
low = low.min(c.low);
high = high.max(c.high);
}
let span = high - low;
if span <= 0.0 {
return low;
}
let width = span / self.bins as f64;
let mut hist = vec![0.0; self.bins];
for c in &self.session {
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 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;
}
}
low + (poc as f64 + 0.5) * width
}
}
impl Indicator for NakedPoc {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
// Test outstanding naked POCs against this candle's range.
self.naked
.retain(|&poc| !(candle.low <= poc && poc <= candle.high));
self.last_close = candle.close;
// Accumulate the session; finalize a POC at the boundary.
self.session.push_back(candle);
if self.session.len() == self.session_len {
let poc = self.session_poc();
self.naked.push(poc);
self.session.clear();
self.ready = true;
}
if !self.ready {
return None;
}
let nearest = self
.naked
.iter()
.copied()
.min_by(|a, b| {
(a - self.last_close)
.abs()
.total_cmp(&(b - self.last_close).abs())
})
.unwrap_or(self.last_close);
self.last = Some(nearest);
Some(nearest)
}
fn reset(&mut self) {
self.session.clear();
self.naked.clear();
self.last_close = 0.0;
self.ready = false;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.session_len
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"NakedPoc"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, volume, 0)
}
#[test]
fn rejects_zero_params() {
assert!(matches!(NakedPoc::new(0, 24), Err(Error::PeriodZero)));
assert!(matches!(NakedPoc::new(20, 0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let n = NakedPoc::new(20, 24).unwrap();
assert_eq!(n.params(), (20, 24));
assert_eq!(n.naked_count(), 0);
assert_eq!(n.warmup_period(), 20);
assert_eq!(n.name(), "NakedPoc");
assert!(!n.is_ready());
assert_eq!(n.value(), None);
}
#[test]
fn first_emission_at_session_end() {
let mut n = NakedPoc::new(4, 8).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| c(101.0, 99.0, 100.0, 1_000.0)).collect();
let out = n.batch(&candles);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn records_session_poc() {
let mut n = NakedPoc::new(4, 16).unwrap();
// A session clustered around 100 -> POC near 100.
n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
assert_eq!(n.naked_count(), 1);
let poc = n.value().unwrap();
assert!(
(poc - 100.0).abs() < 2.0,
"POC should be near 100, got {poc}"
);
}
#[test]
fn revisit_marks_poc_tested() {
let mut n = NakedPoc::new(4, 16).unwrap();
// Session 1 around 100 -> naked POC ~100.
n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
assert_eq!(n.naked_count(), 1);
// Trade away at 120 (does not cover 100) -> still naked.
n.update(c(121.0, 119.0, 120.0, 1_000.0));
assert_eq!(n.naked_count(), 1);
// A candle whose range covers 100 -> POC tested -> removed.
n.update(c(121.0, 95.0, 100.0, 1_000.0));
assert_eq!(n.naked_count(), 0);
}
#[test]
fn empty_naked_reports_close() {
let mut n = NakedPoc::new(4, 16).unwrap();
n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
// Wipe the naked POC with a covering candle.
let out = n.update(c(121.0, 95.0, 117.0, 1_000.0)).unwrap();
assert_eq!(n.naked_count(), 0);
assert!(
(out - 117.0).abs() < 1e-9,
"with no naked POC, output is the close"
);
}
#[test]
fn reset_clears_state() {
let mut n = NakedPoc::new(4, 8).unwrap();
n.batch(&[c(101.0, 99.0, 100.0, 1_000.0); 6]);
assert!(n.is_ready());
n.reset();
assert!(!n.is_ready());
assert_eq!(n.value(), None);
assert_eq!(n.naked_count(), 0);
}
#[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 + 1.0, b - 1.0, b, 1_000.0 + f64::from(i))
})
.collect();
let batch = NakedPoc::new(20, 24).unwrap().batch(&candles);
let mut b = NakedPoc::new(20, 24).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn flat_session_reports_price() {
// A session with zero high-low span returns the session price directly.
let mut n = NakedPoc::new(2, 4).unwrap();
n.update(c(50.0, 50.0, 50.0, 10.0));
assert_eq!(n.update(c(50.0, 50.0, 50.0, 10.0)), Some(50.0));
}
#[test]
fn zero_volume_session_is_handled() {
// Zero-volume candles are skipped in the histogram; a POC still emits.
let mut n = NakedPoc::new(2, 4).unwrap();
n.update(c(60.0, 40.0, 50.0, 0.0));
assert!(n.update(c(60.0, 40.0, 50.0, 0.0)).is_some());
}
#[test]
fn nearest_of_two_naked_pocs() {
// Two untouched POCs at distant prices accumulate; the one nearest the
// last close is reported (exercises the min-by comparison).
let mut n = NakedPoc::new(2, 4).unwrap();
n.update(c(11.0, 9.0, 10.0, 100.0));
n.update(c(11.0, 9.0, 10.0, 100.0)); // POC near 10
n.update(c(101.0, 99.0, 100.0, 100.0));
let v = n.update(c(101.0, 99.0, 100.0, 100.0)).unwrap(); // POC near 100
assert!(
v > 50.0,
"nearest to close 100 should be the upper POC, got {v}"
);
}
}
@@ -0,0 +1,234 @@
//! New Price Lines — the "eight/ten new price lines" exhaustion count.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// New Price Lines — the Japanese "shinne" (new-price) exhaustion count: when the
/// close has made `count` consecutive new highs (or lows), the trend is considered
/// stretched and ripe for a pause or reversal.
///
/// ```text
/// consecutive higher closes form "new price lines" up
/// consecutive lower closes form "new price lines" down
/// signal = 1 once `count` consecutive higher closes (overbought / sell warning)
/// signal = +1 once `count` consecutive lower closes (oversold / buy warning)
/// signal = 0 otherwise
/// ```
///
/// Traditional Japanese practice flags **eight** new price lines (and a stronger
/// **ten** or twelve) as the point where a directional run becomes exhausted —
/// the market has gone up (or down) so many bars in a row that a corrective pause
/// is statistically due. The signal stays active for every bar the streak remains
/// at or above `count`, and clears the moment a close breaks the streak.
///
/// The first value lands on the second bar (one prior close is needed). The
/// output is `+1` / `0` / `1`. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, NewPriceLines};
///
/// let mut indicator = NewPriceLines::new(8).unwrap();
/// let mut last = None;
/// for i in 0..12 {
/// let close = 100.0 + f64::from(i); // 11 consecutive higher closes
/// let c = Candle::new(close, close, close, close, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert_eq!(last, Some(-1.0));
/// ```
#[derive(Debug, Clone)]
pub struct NewPriceLines {
count: usize,
prev_close: Option<f64>,
consec_up: usize,
consec_down: usize,
last: Option<f64>,
}
impl NewPriceLines {
/// Construct a New Price Lines counter that fires at `count` consecutive new
/// closes (classic `8`, stronger `10`/`12`).
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `count < 2`.
pub fn new(count: usize) -> Result<Self> {
if count < 2 {
return Err(Error::InvalidPeriod {
message: "new price lines count must be >= 2",
});
}
Ok(Self {
count,
prev_close: None,
consec_up: 0,
consec_down: 0,
last: None,
})
}
/// Configured count threshold.
pub const fn count(&self) -> usize {
self.count
}
/// Current consecutive streak `(up, down)`.
pub const fn streak(&self) -> (usize, usize) {
(self.consec_up, self.consec_down)
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for NewPriceLines {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let close = candle.close;
let Some(prev) = self.prev_close else {
self.prev_close = Some(close);
return None;
};
if close > prev {
self.consec_up += 1;
self.consec_down = 0;
} else if close < prev {
self.consec_down += 1;
self.consec_up = 0;
} else {
self.consec_up = 0;
self.consec_down = 0;
}
self.prev_close = Some(close);
let v = if self.consec_up >= self.count {
-1.0
} else if self.consec_down >= self.count {
1.0
} else {
0.0
};
self.last = Some(v);
Some(v)
}
fn reset(&mut self) {
self.prev_close = None;
self.consec_up = 0;
self.consec_down = 0;
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"NewPriceLines"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(close: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
}
#[test]
fn rejects_small_count() {
assert!(matches!(
NewPriceLines::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(NewPriceLines::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let n = NewPriceLines::new(8).unwrap();
assert_eq!(n.count(), 8);
assert_eq!(n.streak(), (0, 0));
assert_eq!(n.warmup_period(), 2);
assert_eq!(n.name(), "NewPriceLines");
assert!(!n.is_ready());
assert_eq!(n.value(), None);
}
#[test]
fn first_bar_seeds_without_signal() {
let mut n = NewPriceLines::new(3).unwrap();
assert_eq!(n.update(c(100.0)), None);
assert!(n.update(c(101.0)).is_some());
}
#[test]
fn eight_higher_closes_signal_sell() {
let mut n = NewPriceLines::new(8).unwrap();
// 11 consecutive higher closes -> by the 9th the count reaches 8 -> -1.
let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
let last = n.batch(&candles).into_iter().flatten().last().unwrap();
assert_eq!(last, -1.0);
}
#[test]
fn eight_lower_closes_signal_buy() {
let mut n = NewPriceLines::new(8).unwrap();
let candles: Vec<Candle> = (0..12).map(|i| c(200.0 - f64::from(i))).collect();
let last = n.batch(&candles).into_iter().flatten().last().unwrap();
assert_eq!(last, 1.0);
}
#[test]
fn break_in_streak_clears_signal() {
let mut n = NewPriceLines::new(3).unwrap();
n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]); // streak 3 -> -1
assert_eq!(n.value(), Some(-1.0));
// A lower close breaks the up streak.
assert_eq!(n.update(c(102.0)), Some(0.0));
assert_eq!(n.streak(), (0, 1));
}
#[test]
fn unchanged_close_resets_streak() {
let mut n = NewPriceLines::new(3).unwrap();
n.batch(&[c(100.0), c(101.0), c(102.0)]);
assert_eq!(n.update(c(102.0)), Some(0.0)); // equal -> reset
assert_eq!(n.streak(), (0, 0));
}
#[test]
fn reset_clears_state() {
let mut n = NewPriceLines::new(3).unwrap();
n.batch(&[c(100.0), c(101.0), c(102.0), c(103.0)]);
assert!(n.is_ready());
n.reset();
assert!(!n.is_ready());
assert_eq!(n.value(), None);
assert_eq!(n.streak(), (0, 0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
.collect();
let batch = NewPriceLines::new(8).unwrap().batch(&candles);
let mut b = NewPriceLines::new(8).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,154 @@
//! OI-to-Volume Ratio — open interest relative to traded volume.
use crate::derivatives::DerivativesTick;
use crate::traits::Indicator;
/// OI-to-Volume Ratio — open interest divided by the tick's total taker volume, a
/// measure of how much position is *held* versus *turned over*.
///
/// ```text
/// OIVR = open_interest / (taker_buy_volume + taker_sell_volume)
/// ```
///
/// A high ratio means open interest dwarfs the volume trading it — positions are
/// being held, not churned (low participation, potential complacency or a coiling
/// market). A low ratio means heavy volume relative to outstanding interest —
/// active churn, often around breakouts or capitulation. Watching the ratio change
/// distinguishes new-money trends (OI and volume both rising) from short-covering
/// or position rolls.
///
/// The ratio is non-negative; a tick with zero taker volume 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, OiToVolumeRatio};
///
/// let mut indicator = OiToVolumeRatio::new();
/// let tick = DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 5_000.0, 0.0, 0.0, 400.0, 600.0, 0.0, 0.0, 0).unwrap();
/// let oivr = indicator.update(tick).unwrap();
/// assert!((oivr - 5.0).abs() < 1e-12); // 5000 / (400 + 600)
/// ```
#[derive(Debug, Clone, Default)]
pub struct OiToVolumeRatio {
ready: bool,
}
impl OiToVolumeRatio {
/// Construct a new OI-to-Volume Ratio. The indicator is parameter-free.
#[must_use]
pub const fn new() -> Self {
Self { ready: false }
}
}
impl Indicator for OiToVolumeRatio {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
let volume = tick.taker_buy_volume + tick.taker_sell_volume;
let ratio = if volume > 0.0 {
tick.open_interest / volume
} else {
0.0
};
self.ready = true;
Some(ratio)
}
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 {
"OiToVolumeRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn tick(oi: f64, buy: f64, sell: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(
0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, buy, sell, 0.0, 0.0, 0,
)
}
#[test]
fn accessors_and_metadata() {
let o = OiToVolumeRatio::new();
assert_eq!(o.warmup_period(), 1);
assert_eq!(o.name(), "OiToVolumeRatio");
assert!(!o.is_ready());
}
#[test]
fn ratio_reference_value() {
let mut o = OiToVolumeRatio::new();
assert_relative_eq!(
o.update(tick(5_000.0, 400.0, 600.0)).unwrap(),
5.0,
epsilon = 1e-12
);
}
#[test]
fn more_volume_lowers_ratio() {
let mut o = OiToVolumeRatio::new();
let held = o.update(tick(5_000.0, 100.0, 100.0)).unwrap();
let churned = o.update(tick(5_000.0, 1_000.0, 1_000.0)).unwrap();
assert!(churned < held);
}
#[test]
fn zero_volume_is_zero() {
let mut o = OiToVolumeRatio::new();
assert_relative_eq!(
o.update(tick(5_000.0, 0.0, 0.0)).unwrap(),
0.0,
epsilon = 1e-12
);
}
#[test]
fn ready_after_first_update() {
let mut o = OiToVolumeRatio::new();
assert!(!o.is_ready());
o.update(tick(5_000.0, 100.0, 100.0));
assert!(o.is_ready());
}
#[test]
fn reset_clears_state() {
let mut o = OiToVolumeRatio::new();
o.update(tick(5_000.0, 100.0, 100.0));
assert!(o.is_ready());
o.reset();
assert!(!o.is_ready());
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> = (0..40)
.map(|i| tick(5_000.0, 100.0 + f64::from(i), 100.0))
.collect();
let batch = OiToVolumeRatio::new().batch(&ticks);
let mut b = OiToVolumeRatio::new();
let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,221 @@
//! Open-Interest Momentum — the rate of change of open interest over a lookback.
use std::collections::VecDeque;
use crate::derivatives::DerivativesTick;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Open-Interest Momentum — the percentage rate of change of open interest over a
/// `period`-tick lookback.
///
/// ```text
/// OIM = 100 · (OI_t OI_{tperiod}) / OI_{tperiod}
/// ```
///
/// Where [`OIDelta`](crate::OIDelta) reports the single-tick change in open
/// interest, OI Momentum measures the trend in positioning over a window: positive
/// values mean open interest is expanding (new money entering — a position build
/// that fuels the prevailing move), negative values mean it is contracting
/// (positions being closed — deleveraging or short-covering). Read alongside price:
/// rising OI with rising price is a strong new-long trend, while rising price with
/// falling OI is a short-covering rally on borrowed time.
///
/// The output is a percentage and may be negative. A zero base open interest
/// `period` ticks ago reports `0` rather than dividing by zero. The first value
/// lands after `period + 1` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativesTick, Indicator, OpenInterestMomentum};
///
/// let mut indicator = OpenInterestMomentum::new(5).unwrap();
/// let mut last = None;
/// for i in 0..20 {
/// let oi = 1_000.0 + f64::from(i) * 100.0;
/// let tick = DerivativesTick::new(0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap();
/// last = indicator.update(tick);
/// }
/// assert!(last.unwrap() > 0.0); // expanding OI
/// ```
#[derive(Debug, Clone)]
pub struct OpenInterestMomentum {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl OpenInterestMomentum {
/// Construct an OI Momentum over a `period`-tick lookback.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period + 1),
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
}
}
impl Indicator for OpenInterestMomentum {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
if self.window.len() == self.period + 1 {
self.window.pop_front();
}
self.window.push_back(tick.open_interest);
if self.window.len() < self.period + 1 {
return None;
}
let base = *self.window.front().expect("non-empty");
let current = tick.open_interest;
let oim = if base > 0.0 {
100.0 * (current - base) / base
} else {
0.0
};
self.last = Some(oim);
Some(oim)
}
fn reset(&mut self) {
self.window.clear();
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 {
"OpenInterestMomentum"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn tick(oi: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(
0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
OpenInterestMomentum::new(0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let o = OpenInterestMomentum::new(5).unwrap();
assert_eq!(o.period(), 5);
assert_eq!(o.warmup_period(), 6);
assert_eq!(o.name(), "OpenInterestMomentum");
assert!(!o.is_ready());
assert_eq!(o.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut o = OpenInterestMomentum::new(3).unwrap();
let ticks: Vec<DerivativesTick> = (0..6)
.map(|i| tick(1_000.0 + f64::from(i) * 100.0))
.collect();
let out = o.batch(&ticks);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn reference_value() {
// period 2: OI 1000 -> 1200 over the window -> +20%.
let mut o = OpenInterestMomentum::new(2).unwrap();
let out = o.batch(&[tick(1_000.0), tick(1_100.0), tick(1_200.0)]);
assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-9);
}
#[test]
fn expanding_oi_is_positive() {
let mut o = OpenInterestMomentum::new(5).unwrap();
let ticks: Vec<DerivativesTick> = (0..20)
.map(|i| tick(1_000.0 + f64::from(i) * 100.0))
.collect();
let last = o.batch(&ticks).into_iter().flatten().last().unwrap();
assert!(last > 0.0);
}
#[test]
fn contracting_oi_is_negative() {
let mut o = OpenInterestMomentum::new(5).unwrap();
let ticks: Vec<DerivativesTick> = (0..20)
.map(|i| tick(3_000.0 - f64::from(i) * 100.0))
.collect();
let last = o.batch(&ticks).into_iter().flatten().last().unwrap();
assert!(last < 0.0);
}
#[test]
fn zero_base_is_zero() {
let mut o = OpenInterestMomentum::new(2).unwrap();
let out = o.batch(&[tick(0.0), tick(100.0), tick(200.0)]);
assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut o = OpenInterestMomentum::new(3).unwrap();
o.batch(
&(0..10)
.map(|i| tick(1_000.0 + f64::from(i) * 50.0))
.collect::<Vec<_>>(),
);
assert!(o.is_ready());
o.reset();
assert!(!o.is_ready());
assert_eq!(o.value(), None);
assert_eq!(o.update(tick(1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> = (0..80)
.map(|i| tick(1_000.0 + (f64::from(i) * 0.25).sin() * 300.0))
.collect();
let batch = OpenInterestMomentum::new(10).unwrap().batch(&ticks);
let mut b = OpenInterestMomentum::new(10).unwrap();
let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,139 @@
//! Perpetual Premium Index — the perp mark price relative to spot.
use crate::derivatives::DerivativesTick;
use crate::traits::Indicator;
/// Perpetual Premium Index — the perpetual's mark price relative to the spot index
/// it tracks, as a fraction.
///
/// ```text
/// premium = (mark_price index_price) / index_price
/// ```
///
/// A perpetual swap is pegged to spot by the funding mechanism, but it can still
/// trade at a premium (above spot) or discount (below). A positive premium signals
/// net long demand willing to pay up to hold the perp — bullish positioning, and
/// the proximate driver of positive funding; a negative premium signals the
/// reverse. Sustained extremes flag crowded positioning ripe for a funding-driven
/// mean reversion.
///
/// The output is centred on zero and dimensionless (a fraction; multiply by `100`
/// for percent). `index_price` is validated strictly positive on the tick, so the
/// division is always defined. It is stateless — each tick yields one value (no
/// warmup). Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativesTick, Indicator, PerpetualPremiumIndex};
///
/// let mut indicator = PerpetualPremiumIndex::new();
/// // Mark 101 vs index 100 -> +1% premium.
/// let tick = DerivativesTick::new(0.0, 101.0, 100.0, 101.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap();
/// let premium = indicator.update(tick).unwrap();
/// assert!((premium - 0.01).abs() < 1e-12);
/// ```
#[derive(Debug, Clone, Default)]
pub struct PerpetualPremiumIndex {
ready: bool,
}
impl PerpetualPremiumIndex {
/// Construct a new Perpetual Premium Index. The indicator is parameter-free.
#[must_use]
pub const fn new() -> Self {
Self { ready: false }
}
}
impl Indicator for PerpetualPremiumIndex {
type Input = DerivativesTick;
type Output = f64;
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
let premium = (tick.mark_price - tick.index_price) / tick.index_price;
self.ready = true;
Some(premium)
}
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 {
"PerpetualPremiumIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn tick(mark: f64, index: f64) -> DerivativesTick {
DerivativesTick::new_unchecked(0.0, mark, index, mark, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let p = PerpetualPremiumIndex::new();
assert_eq!(p.warmup_period(), 1);
assert_eq!(p.name(), "PerpetualPremiumIndex");
assert!(!p.is_ready());
}
#[test]
fn premium_reference_value() {
let mut p = PerpetualPremiumIndex::new();
assert_relative_eq!(p.update(tick(101.0, 100.0)).unwrap(), 0.01, epsilon = 1e-12);
}
#[test]
fn discount_is_negative() {
let mut p = PerpetualPremiumIndex::new();
assert!(p.update(tick(99.0, 100.0)).unwrap() < 0.0);
}
#[test]
fn at_par_is_zero() {
let mut p = PerpetualPremiumIndex::new();
assert_relative_eq!(p.update(tick(100.0, 100.0)).unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn ready_after_first_update() {
let mut p = PerpetualPremiumIndex::new();
assert!(!p.is_ready());
p.update(tick(100.0, 100.0));
assert!(p.is_ready());
}
#[test]
fn reset_clears_state() {
let mut p = PerpetualPremiumIndex::new();
p.update(tick(101.0, 100.0));
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
}
#[test]
fn batch_equals_streaming() {
let ticks: Vec<DerivativesTick> = (0..40)
.map(|i| tick(100.0 + (f64::from(i) * 0.3).sin(), 100.0))
.collect();
let batch = PerpetualPremiumIndex::new().batch(&ticks);
let mut b = PerpetualPremiumIndex::new();
let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
+223
View File
@@ -0,0 +1,223 @@
//! PIN — Probability of Informed Trading (single-window EKOP estimate).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::microstructure::Trade;
use crate::traits::Indicator;
/// PIN — the **Probability of Informed Trading**, estimated from the buy/sell order
/// imbalance over a rolling window of trades.
///
/// ```text
/// over the last `window` trades: B = buys, S = sells (B + S = window)
/// PIN ≈ |B S| / (B + S) ∈ [0, 1]
/// ```
///
/// The Easley-Kiefer-O'Hara-Paperman (EKOP) model splits order flow into an
/// uninformed component (balanced buys and sells, rate `ε` per side) and an
/// informed component that trades one-directionally when private information
/// arrives (rate `μ`, probability `α`). The probability that any given trade is
/// information-motivated is `PIN = αμ / (αμ + 2ε)`. Estimated over a single window,
/// the informed flow shows up as the **net imbalance** `|B S|` and the uninformed
/// flow as the balanced remainder, giving the moment estimator above. A high PIN
/// flags a one-sided, likely-informed market; a low PIN flags balanced, uninformed
/// flow.
///
/// This is distinct from [`Vpin`](crate::Vpin), the volume-synchronised variant
/// that buckets by volume and uses bulk-volume classification; here trades are
/// counted in event time and classified by their tagged aggressor side. The full
/// PIN is fit by maximum likelihood over many periods — this single-window
/// estimator is the streaming moment approximation. The output is in `[0, 1]`; the
/// first value lands after `window` trades.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Pin, Side, Trade};
///
/// let mut indicator = Pin::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// // All buys -> maximally one-sided -> PIN 1.
/// last = indicator.update(Trade::new(100.0, 1.0, Side::Buy, i).unwrap());
/// }
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct Pin {
window: usize,
sides: VecDeque<f64>,
buy_count: usize,
last: Option<f64>,
}
impl Pin {
/// Construct a PIN estimator over `window` trades.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `window == 0`.
pub fn new(window: usize) -> Result<Self> {
if window == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
window,
sides: VecDeque::with_capacity(window),
buy_count: 0,
last: None,
})
}
/// Configured window of trades.
pub const fn window(&self) -> usize {
self.window
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Pin {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
let is_buy = trade.side.sign() > 0.0;
if self.sides.len() == self.window {
let old = self.sides.pop_front().expect("non-empty");
if old > 0.0 {
self.buy_count -= 1;
}
}
self.sides.push_back(if is_buy { 1.0 } else { 0.0 });
if is_buy {
self.buy_count += 1;
}
if self.sides.len() < self.window {
return None;
}
// The window is full and `window >= 1` (zero is rejected at
// construction), so the trade count is always positive — `|B - S| / N`
// needs no zero guard.
let buys = self.buy_count as f64;
let sells = self.window as f64 - buys;
let total = self.window as f64;
let pin = (buys - sells).abs() / total;
self.last = Some(pin);
Some(pin)
}
fn reset(&mut self) {
self.sides.clear();
self.buy_count = 0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.window
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"PIN"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn buy() -> Trade {
Trade::new_unchecked(100.0, 1.0, Side::Buy, 0)
}
fn sell() -> Trade {
Trade::new_unchecked(100.0, 1.0, Side::Sell, 0)
}
#[test]
fn rejects_zero_window() {
assert!(matches!(Pin::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let p = Pin::new(20).unwrap();
assert_eq!(p.window(), 20);
assert_eq!(p.warmup_period(), 20);
assert_eq!(p.name(), "PIN");
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut p = Pin::new(4).unwrap();
let out = p.batch(&[buy(), buy(), buy(), buy(), buy()]);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn one_sided_flow_is_one() {
let mut p = Pin::new(10).unwrap();
let trades: Vec<Trade> = (0..20).map(|_| buy()).collect();
let last = p.batch(&trades).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-12);
}
#[test]
fn balanced_flow_is_zero() {
let mut p = Pin::new(10).unwrap();
let trades: Vec<Trade> = (0..20)
.map(|i| if i % 2 == 0 { buy() } else { sell() })
.collect();
let last = p.batch(&trades).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let mut p = Pin::new(16).unwrap();
let trades: Vec<Trade> = (0..200)
.map(|i| if (i * 5 % 13) < 8 { buy() } else { sell() })
.collect();
for v in p.batch(&trades).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v));
}
}
#[test]
fn reset_clears_state() {
let mut p = Pin::new(4).unwrap();
p.batch(&[buy(), buy(), sell(), buy()]);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.value(), None);
assert_eq!(p.update(buy()), None);
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..120)
.map(|i| if i % 3 == 0 { sell() } else { buy() })
.collect();
let batch = Pin::new(16).unwrap().batch(&trades);
let mut b = Pin::new(16).unwrap();
let streamed: Vec<_> = trades.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,285 @@
//! Profile Shape — classifies the volume profile as b-shape, P-shape, or D/normal.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Profile Shape — classifies a rolling volume profile by where its point of
/// control (POC) sits within the range: `b`, `P`, or `D` (normal).
///
/// ```text
/// build a `bins`-bucket volume profile over the last `period` candles
/// poc_idx = bin with the most volume
/// +1 P-shape : POC in the upper third (heavy top, thin tail down) — short-covering / accumulation
/// 1 b-shape : POC in the lower third (heavy bottom, thin tail up) — long-liquidation / distribution
/// 0 D/normal: POC in the middle third (balanced bell)
/// ```
///
/// Market Profile readers classify the day's shape by the location of the heaviest
/// trading. A **P-shape** (control high, a thin tail beneath) typically marks
/// short-covering or the start of accumulation; a **b-shape** (control low, thin
/// tail above) marks long liquidation or distribution; a **D-shape** is a balanced,
/// two-sided day. Reducing the profile to this three-way code gives a compact,
/// streaming read of market posture.
///
/// The output is `+1` / `0` / `1`. The first value lands after `period` candles;
/// each `update` rebuilds the profile in O(`period · bins`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ProfileShape};
///
/// let mut indicator = ProfileShape::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 ProfileShape {
period: usize,
bins: usize,
window: VecDeque<Candle>,
last: Option<f64>,
}
impl ProfileShape {
/// Construct a Profile Shape classifier.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period` is zero, or
/// [`Error::InvalidPeriod`] if `bins < 3` (the three-way split needs three
/// zones).
pub fn new(period: usize, bins: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if bins < 3 {
return Err(Error::InvalidPeriod {
message: "profile shape needs bins >= 3",
});
}
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<f64> {
self.last
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn poc_index(&self) -> usize {
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 {
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 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 mut poc_idx = 0;
let mut poc_vol = f64::NEG_INFINITY;
for (idx, &vol) in hist.iter().enumerate() {
if vol > poc_vol {
poc_vol = vol;
poc_idx = idx;
}
}
poc_idx
}
}
impl Indicator for ProfileShape {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(candle);
if self.window.len() < self.period {
return None;
}
let poc = self.poc_index();
let lower = self.bins / 3;
let upper = self.bins - self.bins / 3;
let shape = if poc >= upper {
1.0
} else if poc < lower {
-1.0
} else {
0.0
};
self.last = Some(shape);
Some(shape)
}
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 {
"ProfileShape"
}
}
#[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!(ProfileShape::new(0, 24), Err(Error::PeriodZero)));
assert!(matches!(
ProfileShape::new(20, 2),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let p = ProfileShape::new(20, 24).unwrap();
assert_eq!(p.params(), (20, 24));
assert_eq!(p.warmup_period(), 20);
assert_eq!(p.name(), "ProfileShape");
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut p = ProfileShape::new(4, 9).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 heavy_top_is_p_shape() {
// Volume concentrated near the top of the range -> P-shape -> +1.
let mut p = ProfileShape::new(6, 9).unwrap();
let mut candles: Vec<Candle> = (0..5).map(|_| c(119.0, 117.0, 5_000.0)).collect();
candles.push(c(119.0, 80.0, 50.0)); // a thin tail down to 80
let last = p.batch(&candles).into_iter().flatten().last().unwrap();
assert_eq!(last, 1.0);
}
#[test]
fn heavy_bottom_is_b_shape() {
let mut p = ProfileShape::new(6, 9).unwrap();
let mut candles: Vec<Candle> = (0..5).map(|_| c(83.0, 81.0, 5_000.0)).collect();
candles.push(c(120.0, 81.0, 50.0)); // a thin tail up to 120
let last = p.batch(&candles).into_iter().flatten().last().unwrap();
assert_eq!(last, -1.0);
}
#[test]
fn balanced_is_d_shape() {
// Volume concentrated in the middle -> D/normal -> 0.
let mut p = ProfileShape::new(6, 9).unwrap();
let mut candles: Vec<Candle> = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
candles.push(c(120.0, 80.0, 50.0)); // thin tails both ways, POC in the middle
let last = p.batch(&candles).into_iter().flatten().last().unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn reset_clears_state() {
let mut p = ProfileShape::new(4, 9).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..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 = ProfileShape::new(20, 24).unwrap().batch(&candles);
let mut b = ProfileShape::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 skips the histogram pass entirely.
let mut p = ProfileShape::new(2, 4).unwrap();
p.update(c(50.0, 50.0, 10.0));
assert!(p.update(c(50.0, 50.0, 10.0)).is_some());
}
#[test]
fn zero_volume_window_is_handled() {
// Non-flat window of zero-volume candles hits the skip path.
let mut p = ProfileShape::new(2, 4).unwrap();
p.update(c(60.0, 40.0, 0.0));
assert!(p.update(c(60.0, 40.0, 0.0)).is_some());
}
}
@@ -0,0 +1,227 @@
//! Range bar builder — fixed price-range bars with no reversal penalty.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed range bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RangeBar {
/// Price at the bar's origin edge.
pub open: f64,
/// Price at the bar's far edge (`open ± range`).
pub close: f64,
/// `+1` for an up bar, `-1` for a down bar.
pub direction: i8,
}
/// Range bar builder using a fixed price increment on close prices.
///
/// A range bar completes every time price travels a fixed `range` from the current
/// anchor, in *either* direction. This is the key difference from
/// [`RenkoBars`](crate::RenkoBars): Renko imposes a `2 * box_size` penalty to
/// reverse direction, so it filters out small oscillations; range bars have **no
/// reversal penalty** — a move of exactly `range` against the trend prints a bar
/// immediately. Range bars therefore track every leg of price movement, while Renko
/// smooths them.
///
/// Construction rules:
///
/// - The first candle seeds the anchor and prints no bar.
/// - Each subsequent candle prints one bar for every `range` of close movement away
/// from the anchor; a candle that gaps several ranges prints them all in one
/// [`BarBuilder::update`] call.
/// - Bars are aligned to the `range` grid relative to the seed price.
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, RangeBars};
///
/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
/// let mut bars = RangeBars::new(1.0).unwrap();
/// assert!(bars.update(flat(10.0)).is_empty()); // seed
/// let up = bars.update(flat(12.0)); // +2 ranges
/// assert_eq!(up.len(), 2);
/// let down = bars.update(flat(11.0)); // -1 range, no penalty
/// assert_eq!(down.len(), 1);
/// ```
#[derive(Debug, Clone)]
pub struct RangeBars {
range: f64,
anchor: Option<f64>,
}
impl RangeBars {
/// Construct a range-bar builder with the given price increment.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `range` is not finite and positive.
pub fn new(range: f64) -> Result<Self> {
if !range.is_finite() || range <= 0.0 {
return Err(Error::InvalidPeriod {
message: "range must be finite and positive",
});
}
Ok(Self {
range,
anchor: None,
})
}
/// Configured price range.
pub const fn range(&self) -> f64 {
self.range
}
/// Current anchor level (the close of the last completed bar, or the seed
/// price before any bar has formed).
pub const fn anchor(&self) -> Option<f64> {
self.anchor
}
}
impl BarBuilder for RangeBars {
type Bar = RangeBar;
fn update(&mut self, candle: Candle) -> Vec<RangeBar> {
let close = candle.close;
let Some(mut anchor) = self.anchor else {
self.anchor = Some(close);
return Vec::new();
};
let range = self.range;
let mut bars = Vec::new();
while close >= anchor + range {
bars.push(RangeBar {
open: anchor,
close: anchor + range,
direction: 1,
});
anchor += range;
}
while close <= anchor - range {
bars.push(RangeBar {
open: anchor,
close: anchor - range,
direction: -1,
});
anchor -= range;
}
self.anchor = Some(anchor);
bars
}
fn reset(&mut self) {
self.anchor = None;
}
fn name(&self) -> &'static str {
"RangeBars"
}
}
#[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_range() {
assert!(matches!(
RangeBars::new(0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
RangeBars::new(-1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
RangeBars::new(f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bars = RangeBars::new(2.5).unwrap();
assert_eq!(bars.name(), "RangeBars");
assert_relative_eq!(bars.range(), 2.5, epsilon = 1e-12);
assert_eq!(bars.anchor(), None);
}
#[test]
fn first_candle_seeds_without_bar() {
let mut bars = RangeBars::new(1.0).unwrap();
assert!(bars.update(flat(10.0)).is_empty());
assert_eq!(bars.anchor(), Some(10.0));
}
#[test]
fn up_move_prints_aligned_bars() {
let mut bars = RangeBars::new(1.0).unwrap();
bars.update(flat(10.0));
let up = bars.update(flat(13.0));
assert_eq!(up.len(), 3);
assert_relative_eq!(up[0].open, 10.0, epsilon = 1e-12);
assert_relative_eq!(up[2].close, 13.0, epsilon = 1e-12);
assert!(up.iter().all(|b| b.direction == 1));
assert_eq!(bars.anchor(), Some(13.0));
}
#[test]
fn down_move_prints_aligned_bars() {
let mut bars = RangeBars::new(1.0).unwrap();
bars.update(flat(10.0));
let down = bars.update(flat(7.0));
assert_eq!(down.len(), 3);
assert!(down.iter().all(|b| b.direction == -1));
assert_relative_eq!(down[2].close, 7.0, epsilon = 1e-12);
}
#[test]
fn reversal_needs_only_one_range() {
// Unlike Renko, a single-range move against the trend prints immediately.
let mut bars = RangeBars::new(1.0).unwrap();
bars.update(flat(10.0));
bars.update(flat(12.0)); // anchor 12, up
let down = bars.update(flat(11.0)); // drop of exactly one range
assert_eq!(down.len(), 1);
assert_eq!(down[0].direction, -1);
assert_relative_eq!(down[0].close, 11.0, epsilon = 1e-12);
assert_eq!(bars.anchor(), Some(11.0));
}
#[test]
fn small_move_prints_nothing() {
let mut bars = RangeBars::new(1.0).unwrap();
bars.update(flat(10.0));
assert!(bars.update(flat(10.5)).is_empty());
assert_eq!(bars.anchor(), Some(10.0));
}
#[test]
fn reset_clears_state() {
let mut bars = RangeBars::new(1.0).unwrap();
bars.update(flat(10.0));
bars.update(flat(13.0));
bars.reset();
assert_eq!(bars.anchor(), None);
assert!(bars.update(flat(50.0)).is_empty());
assert_eq!(bars.anchor(), Some(50.0));
}
#[test]
fn batch_concatenates_completed_bars() {
let mut bars = RangeBars::new(1.0).unwrap();
let candles = [flat(10.0), flat(12.0), flat(13.0)];
let out = bars.batch(&candles);
assert_eq!(out.len(), 3);
assert!(out.iter().all(|b| b.direction == 1));
}
}
@@ -0,0 +1,257 @@
//! Run bar builder (simplified López de Prado) — sample on runs of same-signed ticks.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed run bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RunBar {
/// 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,
/// Length of the run that closed the bar (`== run_length`).
pub length: usize,
/// `+1` if a buy run closed the bar, `-1` if a sell run.
pub direction: i8,
}
/// Run bar builder — a **simplified** form of López de Prado's run bars.
///
/// A *run* is an uninterrupted sequence of same-signed ticks: a streak of up-ticks
/// (a buy run) or down-ticks (a sell run), with unchanged closes extending the
/// current run. This builder counts the current run's length and closes a bar when
/// it reaches `run_length`; a tick in the opposite direction restarts the run from
/// one. Where [`ImbalanceBars`](crate::ImbalanceBars) sample on the *net* signed
/// imbalance (which oscillating flow can cancel back to zero), run bars sample on
/// *persistence*: they fire only when the market pushes the same way without
/// interruption, making them a cleaner sequential-trend detector.
///
/// **Simplification.** The full method estimates a *dynamic* expected run length
/// from an EWMA and can weight runs by volume or traded value. This builder uses a
/// **fixed** run-length threshold on unweighted ticks. See López de Prado (2018),
/// ch. 2, for the adaptive estimator and weighted variants.
///
/// At most one bar closes per candle, so [`BarBuilder::update`] returns either an
/// empty vector or a single [`RunBar`].
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, RunBars};
///
/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
/// let mut bars = RunBars::new(3).unwrap();
/// bars.update(flat(10.0)); // seed
/// bars.update(flat(11.0)); // run 1
/// bars.update(flat(12.0)); // run 2
/// let out = bars.update(flat(13.0)); // run 3 -> close
/// assert_eq!(out.len(), 1);
/// assert_eq!(out[0].direction, 1);
/// ```
#[derive(Debug, Clone)]
pub struct RunBars {
run_length: usize,
count: usize,
open: f64,
high: f64,
low: f64,
close: f64,
prev_close: Option<f64>,
run_sign: i8,
run_len: usize,
}
impl RunBars {
/// Construct a run-bar builder that closes a bar on a run of `run_length` ticks.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `run_length == 0`.
pub fn new(run_length: usize) -> Result<Self> {
if run_length == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
run_length,
count: 0,
open: 0.0,
high: 0.0,
low: 0.0,
close: 0.0,
prev_close: None,
run_sign: 0,
run_len: 0,
})
}
/// Configured run length that closes a bar.
pub const fn run_length(&self) -> usize {
self.run_length
}
/// Length of the in-progress run.
pub const fn run(&self) -> usize {
self.run_len
}
}
impl BarBuilder for RunBars {
type Bar = RunBar;
fn update(&mut self, candle: Candle) -> Vec<RunBar> {
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 directional = if candle.close > prev {
1
} else if candle.close < prev {
-1
} else {
0
};
if directional == 0 {
// A flat tick extends the current run (if one is under way).
if self.run_sign != 0 {
self.run_len += 1;
}
} else if directional == self.run_sign {
self.run_len += 1;
} else {
self.run_sign = directional;
self.run_len = 1;
}
}
self.prev_close = Some(candle.close);
if self.run_sign == 0 || self.run_len < self.run_length {
return Vec::new();
}
let bar = RunBar {
open: self.open,
high: self.high,
low: self.low,
close: self.close,
length: self.run_len,
direction: self.run_sign,
};
self.count = 0;
self.run_sign = 0;
self.run_len = 0;
vec![bar]
}
fn reset(&mut self) {
self.count = 0;
self.prev_close = None;
self.run_sign = 0;
self.run_len = 0;
}
fn name(&self) -> &'static str {
"RunBars"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn flat(price: f64) -> Candle {
Candle::new(price, price, price, price, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_run_length() {
assert!(matches!(RunBars::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let bars = RunBars::new(5).unwrap();
assert_eq!(bars.run_length(), 5);
assert_eq!(bars.run(), 0);
assert_eq!(bars.name(), "RunBars");
}
#[test]
fn buy_run_closes_up_bar() {
let mut bars = RunBars::new(3).unwrap();
bars.update(flat(10.0)); // seed
bars.update(flat(11.0)); // run 1
bars.update(flat(12.0)); // run 2
let out = bars.update(flat(13.0)); // run 3
assert_eq!(out.len(), 1);
assert_eq!(out[0].direction, 1);
assert_eq!(out[0].length, 3);
}
#[test]
fn sell_run_closes_down_bar() {
let mut bars = RunBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(9.0)); // run 1
bars.update(flat(8.0)); // run 2
let out = bars.update(flat(7.0)); // run 3
assert_eq!(out.len(), 1);
assert_eq!(out[0].direction, -1);
}
#[test]
fn opposite_tick_restarts_run() {
let mut bars = RunBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // up run 1
bars.update(flat(12.0)); // up run 2
bars.update(flat(11.0)); // down -> run restarts at 1
assert_eq!(bars.run(), 1);
}
#[test]
fn flat_tick_extends_run() {
let mut bars = RunBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // run 1
bars.update(flat(11.0)); // flat -> run 2
let out = bars.update(flat(12.0)); // run 3
assert_eq!(out.len(), 1);
assert_eq!(out[0].direction, 1);
}
#[test]
fn reset_clears_state() {
let mut bars = RunBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0));
bars.reset();
assert_eq!(bars.run(), 0);
assert!(bars.update(flat(50.0)).is_empty());
}
#[test]
fn batch_concatenates_completed_bars() {
let mut bars = RunBars::new(2).unwrap();
let candles = [
flat(10.0),
flat(11.0), // run 1
flat(12.0), // run 2 -> close
flat(13.0), // run 1
flat(14.0), // run 2 -> close
];
let out = bars.batch(&candles);
assert_eq!(out.len(), 2);
assert!(out.iter().all(|b| b.direction == 1));
}
}
@@ -0,0 +1,253 @@
//! Single Prints — count of price levels touched by exactly one bar (low acceptance).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Single Prints — the number of price levels (bins) in the rolling profile that
/// were touched by **exactly one** bar, marking zones of low acceptance / fast
/// movement.
///
/// ```text
/// for each of `bins` price levels over the last `period` candles:
/// touches = number of bars whose high-low range covers that level
/// SinglePrints = count of levels with touches == 1
/// ```
///
/// In Market Profile a "single print" is a price the market traded through so
/// quickly that only one time-period printed there — a footprint of an aggressive,
/// one-sided move with little two-way trade. Single prints often act as support or
/// resistance on a retest (the imbalance gets "repaired") and mark the edges of
/// rapid moves. Counting them per profile gives a streaming gauge of how much of
/// the recent range was traversed without acceptance: a high count means a fast,
/// trending, low-rotation market; a low count means a balanced, well-traded range.
///
/// The output is a non-negative count. The first value lands after `period`
/// candles; each `update` rebuilds the touch histogram in O(`period · bins`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SinglePrints};
///
/// let mut indicator = SinglePrints::new(20, 24).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i); // a one-directional ramp -> many single prints
/// let c = Candle::new(base, base + 0.5, base - 0.5, base, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SinglePrints {
period: usize,
bins: usize,
window: VecDeque<Candle>,
last: Option<f64>,
}
impl SinglePrints {
/// Construct a Single Prints counter.
///
/// # 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<f64> {
self.last
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn count_single_prints(&self) -> usize {
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 0;
}
let width = span / self.bins as f64;
let mut touches = vec![0u32; self.bins];
for c in &self.window {
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);
for t in touches.iter_mut().take(hi_idx + 1).skip(lo_idx) {
*t += 1;
}
}
touches.iter().filter(|&&t| t == 1).count()
}
}
impl Indicator for SinglePrints {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(candle);
if self.window.len() < self.period {
return None;
}
let count = self.count_single_prints() as f64;
self.last = Some(count);
Some(count)
}
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 {
"SinglePrints"
}
}
#[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,
)
}
#[test]
fn rejects_zero_params() {
assert!(matches!(SinglePrints::new(0, 24), Err(Error::PeriodZero)));
assert!(matches!(SinglePrints::new(20, 0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let s = SinglePrints::new(20, 24).unwrap();
assert_eq!(s.params(), (20, 24));
assert_eq!(s.warmup_period(), 20);
assert_eq!(s.name(), "SinglePrints");
assert!(!s.is_ready());
assert_eq!(s.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut s = SinglePrints::new(4, 8).unwrap();
let candles: Vec<Candle> = (0..6)
.map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
.collect();
let out = s.batch(&candles);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn flat_range_has_no_single_prints() {
// Every bar covers the same single price -> zero span -> 0.
let mut s = SinglePrints::new(4, 8).unwrap();
let last = s
.batch(&[c(100.0, 100.0); 6])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn ramp_has_many_single_prints() {
// A one-directional ramp visits most levels exactly once.
let mut s = SinglePrints::new(10, 24).unwrap();
let candles: Vec<Candle> = (0..10)
.map(|i| c(100.5 + f64::from(i), 99.5 + f64::from(i)))
.collect();
let last = s.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last > 0.0,
"a ramp should produce single prints, got {last}"
);
}
#[test]
fn output_non_negative() {
let mut s = SinglePrints::new(14, 24).unwrap();
for v in s
.batch(
&(0..60)
.map(|i| c(110.0 + (f64::from(i) * 0.3).sin() * 8.0, 90.0))
.collect::<Vec<_>>(),
)
.into_iter()
.flatten()
{
assert!(v >= 0.0);
}
}
#[test]
fn reset_clears_state() {
let mut s = SinglePrints::new(4, 8).unwrap();
s.batch(
&(0..6)
.map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
.collect::<Vec<_>>(),
);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.value(), None);
assert_eq!(s.update(c(101.0, 99.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))
.collect();
let batch = SinglePrints::new(20, 24).unwrap().batch(&candles);
let mut b = SinglePrints::new(20, 24).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,216 @@
//! Sterling Ratio — mean return over the average drawdown of the equity curve.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Sterling 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)
/// Sterling = mean(returns) / mean(dd_t)
/// ```
///
/// The Sterling Ratio rewards return per unit of *typical* pain: it divides the
/// average per-period return by the **average drawdown** experienced along the
/// compounded equity curve. Of the three drawdown-based ratios Wickra ships it is
/// the gentlest on outliers — averaging the drawdowns means one deep crater does
/// not dominate the way it does in the [`BurkeRatio`](crate::BurkeRatio) (which
/// sums squared drawdowns) or the [`MartinRatio`](crate::MartinRatio) (which uses
/// the root-mean-square percentage drawdown). A window that never draws down has
/// zero average drawdown 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, SterlingRatio};
///
/// let mut indicator = SterlingRatio::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 SterlingRatio {
period: usize,
window: VecDeque<f64>,
}
impl SterlingRatio {
/// Construct a Sterling 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: "sterling 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 = 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);
sum_drawdown += (peak - equity) / peak;
}
let avg_drawdown = sum_drawdown / length;
if avg_drawdown > 0.0 {
(sum_return / length) / avg_drawdown
} else {
0.0
}
}
}
impl Indicator for SterlingRatio {
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 {
"SterlingRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
SterlingRatio::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let sr = SterlingRatio::new(12).unwrap();
assert_eq!(sr.period(), 12);
assert_eq!(sr.warmup_period(), 12);
assert_eq!(sr.name(), "SterlingRatio");
assert!(!sr.is_ready());
}
#[test]
fn reference_value() {
// returns [0.1, -0.1, 0.1]:
// equity 1.1, 0.99, 1.089; peak stays 1.1.
// dd = [0, 0.1, 0.01]; avg_dd = 0.11/3; mean_return = 0.1/3.
// Sterling = (0.1/3) / (0.11/3) = 0.1/0.11.
let mut sr = SterlingRatio::new(3).unwrap();
let out = sr.batch(&[0.1, -0.1, 0.1]);
assert_relative_eq!(out[2].unwrap(), 0.1_f64 / 0.11, epsilon = 1e-9);
}
#[test]
fn no_drawdown_is_zero() {
// Monotonically rising equity never draws down.
let mut sr = SterlingRatio::new(3).unwrap();
let last = sr
.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 sr = SterlingRatio::new(3).unwrap();
let last = sr
.batch(&[-0.05, -0.02, -0.03])
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last < 0.0);
}
#[test]
fn ignores_non_finite_input() {
let mut sr = SterlingRatio::new(3).unwrap();
assert_eq!(sr.update(0.1), None);
assert_eq!(sr.update(f64::NAN), None);
assert_eq!(sr.update(-0.1), None);
assert!(sr.update(0.1).is_some());
}
#[test]
fn reset_clears_state() {
let mut sr = SterlingRatio::new(3).unwrap();
sr.batch(&[0.1, -0.1, 0.1]);
assert!(sr.is_ready());
sr.reset();
assert!(!sr.is_ready());
assert_eq!(sr.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 = SterlingRatio::new(12).unwrap().batch(&rets);
let mut streamer = SterlingRatio::new(12).unwrap();
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,224 @@
//! Tail Ratio — the right tail (95th percentile) over the absolute left tail (5th percentile).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Tail Ratio over a trailing window of `period` returns.
///
/// ```text
/// TailRatio = P95(returns) / |P5(returns)|
/// ```
///
/// The Tail Ratio contrasts the magnitude of the best outcomes against the worst:
/// the 95th percentile of the return distribution divided by the absolute value of
/// the 5th percentile. A value above `1.0` means the right tail (upside surprises)
/// is fatter than the left tail (downside surprises); below `1.0` means crashes are
/// larger than rallies. It is a distribution-shape statistic, distinct from the
/// average-based [`SharpeRatio`](crate::SharpeRatio): two series with the same mean
/// and variance can have very different tail ratios.
///
/// Percentiles are computed by linear interpolation over the sorted window
/// (the same rule `NumPy` uses by default). A window whose 5th percentile is exactly
/// zero has no measurable left tail and the indicator 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, TailRatio};
///
/// let mut indicator = TailRatio::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 TailRatio {
period: usize,
window: VecDeque<f64>,
}
impl TailRatio {
/// Construct a Tail Ratio over `period` returns.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `period < 2` (percentiles need at least
/// two observations to interpolate).
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "tail 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 sorted: Vec<f64> = self.window.iter().copied().collect();
sorted.sort_unstable_by(f64::total_cmp);
let upper = percentile(&sorted, 95.0);
let lower = percentile(&sorted, 5.0).abs();
if lower > 0.0 {
upper / lower
} else {
0.0
}
}
}
/// 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 TailRatio {
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 {
"TailRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
TailRatio::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
TailRatio::new(0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let tr = TailRatio::new(20).unwrap();
assert_eq!(tr.period(), 20);
assert_eq!(tr.warmup_period(), 20);
assert_eq!(tr.name(), "TailRatio");
assert!(!tr.is_ready());
}
#[test]
fn reference_value() {
// sorted window [-0.04, -0.02, 0.0, 0.02, 0.04], last_index = 4.
// P95: rank 3.8 -> 0.02 + 0.8*(0.04-0.02) = 0.036.
// P5: rank 0.2 -> -0.04 + 0.2*(0.02) = -0.036, abs 0.036.
// ratio = 0.036 / 0.036 = 1.0.
let mut tr = TailRatio::new(5).unwrap();
let out = tr.batch(&[-0.04, -0.02, 0.0, 0.02, 0.04]);
assert_relative_eq!(out[4].unwrap(), 1.0, epsilon = 1e-9);
}
#[test]
fn fatter_right_tail_exceeds_one() {
let mut tr = TailRatio::new(5).unwrap();
let out = tr.batch(&[-0.01, 0.0, 0.01, 0.02, 0.10]);
assert!(out[4].unwrap() > 1.0);
}
#[test]
fn flat_window_is_zero() {
let mut tr = TailRatio::new(4).unwrap();
let last = tr.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 tr = TailRatio::new(3).unwrap();
assert_eq!(tr.update(0.01), None);
assert_eq!(tr.update(f64::NAN), None);
assert_eq!(tr.update(0.02), None);
assert!(tr.update(0.03).is_some());
}
#[test]
fn reset_clears_state() {
let mut tr = TailRatio::new(3).unwrap();
tr.batch(&[-0.01, 0.0, 0.02]);
assert!(tr.is_ready());
tr.reset();
assert!(!tr.is_ready());
assert_eq!(tr.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 = TailRatio::new(15).unwrap().batch(&rets);
let mut streamer = TailRatio::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() {
// When the rank floor reaches the final index (the 100th percentile), the
// helper returns the largest element without interpolating past the end.
assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
}
}
@@ -0,0 +1,305 @@
//! Three-Line-Break bar builder — line-break chart segments driven by close prices.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed line-break line.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LineBreakBar {
/// Price where the line began (the previous line's far edge).
pub open: f64,
/// Price where the line ended (the new close that drew it).
pub close: f64,
/// `+1` for a rising line, `-1` for a falling line.
pub direction: i8,
}
/// Three-Line-Break bar builder using the classic close-based reversal rule.
///
/// A line-break chart draws a new line in the trend direction whenever the close
/// makes a new extreme, and only reverses when the close breaks the extreme of the
/// previous `lines` lines (three by default — hence "three-line break"). This filters
/// minor noise: a pullback that fails to exceed the last three lines is ignored
/// entirely, so the chart isolates meaningful reversals.
///
/// This is the **bar-builder** counterpart of the
/// [`ThreeLineBreak`](crate::ThreeLineBreak) indicator: the indicator reports the
/// current line *state* as a streaming value, whereas this builder emits each
/// completed line as a [`LineBreakBar`] so you can reconstruct the full line-break
/// chart. At most one line forms per candle, so [`BarBuilder::update`] returns either
/// an empty vector or a single bar.
///
/// Construction rules:
///
/// - The first candle seeds a reference close and prints nothing.
/// - The first subsequent move (up or down) draws the first line.
/// - In an up-trend a close above the last line's top extends it (a new up line); a
/// close below the lowest low of the last `lines` lines reverses to a down line.
/// The down-trend is symmetric.
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, ThreeLineBreakBars};
///
/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
/// let mut bars = ThreeLineBreakBars::new(3).unwrap();
/// bars.update(flat(10.0)); // seed
/// let first = bars.update(flat(11.0)); // first up line
/// assert_eq!(first.len(), 1);
/// assert_eq!(first[0].direction, 1);
/// ```
#[derive(Debug, Clone)]
pub struct ThreeLineBreakBars {
lines: usize,
seed: Option<f64>,
recent: VecDeque<LineBreakBar>,
}
impl ThreeLineBreakBars {
/// Construct a line-break builder that reverses on a break of the last `lines`
/// lines (3 for the classic three-line break).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `lines == 0`.
pub fn new(lines: usize) -> Result<Self> {
if lines == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
lines,
seed: None,
recent: VecDeque::with_capacity(lines),
})
}
/// Configured number of lines a reversal must break.
pub const fn lines(&self) -> usize {
self.lines
}
/// Number of recent lines currently tracked for the reversal test.
pub fn tracked(&self) -> usize {
self.recent.len()
}
fn push_line(&mut self, bar: LineBreakBar) {
if self.recent.len() == self.lines {
self.recent.pop_front();
}
self.recent.push_back(bar);
}
fn lowest_low(&self) -> f64 {
self.recent
.iter()
.map(|bar| bar.open.min(bar.close))
.fold(f64::INFINITY, f64::min)
}
fn highest_high(&self) -> f64 {
self.recent
.iter()
.map(|bar| bar.open.max(bar.close))
.fold(f64::NEG_INFINITY, f64::max)
}
}
impl BarBuilder for ThreeLineBreakBars {
type Bar = LineBreakBar;
fn update(&mut self, candle: Candle) -> Vec<LineBreakBar> {
let close = candle.close;
let Some(last) = self.recent.back().copied() else {
// No line yet: seed, then draw the first line on the first move.
let Some(seed) = self.seed else {
self.seed = Some(close);
return Vec::new();
};
let bar = if close > seed {
LineBreakBar {
open: seed,
close,
direction: 1,
}
} else if close < seed {
LineBreakBar {
open: seed,
close,
direction: -1,
}
} else {
return Vec::new();
};
self.push_line(bar);
return vec![bar];
};
let new_bar = if last.direction > 0 {
if close > last.close {
Some(LineBreakBar {
open: last.close,
close,
direction: 1,
})
} else if close < self.lowest_low() {
Some(LineBreakBar {
open: last.close,
close,
direction: -1,
})
} else {
None
}
} else if close < last.close {
Some(LineBreakBar {
open: last.close,
close,
direction: -1,
})
} else if close > self.highest_high() {
Some(LineBreakBar {
open: last.close,
close,
direction: 1,
})
} else {
None
};
if let Some(bar) = new_bar {
self.push_line(bar);
vec![bar]
} else {
Vec::new()
}
}
fn reset(&mut self) {
self.seed = None;
self.recent.clear();
}
fn name(&self) -> &'static str {
"ThreeLineBreakBars"
}
}
#[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_zero_lines() {
assert!(matches!(ThreeLineBreakBars::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let bars = ThreeLineBreakBars::new(3).unwrap();
assert_eq!(bars.lines(), 3);
assert_eq!(bars.tracked(), 0);
assert_eq!(bars.name(), "ThreeLineBreakBars");
}
#[test]
fn seed_then_first_line() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
assert!(bars.update(flat(10.0)).is_empty()); // seed
let first = bars.update(flat(11.0));
assert_eq!(first.len(), 1);
assert_eq!(first[0].direction, 1);
assert_relative_eq!(first[0].open, 10.0, epsilon = 1e-12);
assert_relative_eq!(first[0].close, 11.0, epsilon = 1e-12);
}
#[test]
fn new_high_extends_up_line() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // line 1 up
let cont = bars.update(flat(12.0)); // new high -> extend
assert_eq!(cont.len(), 1);
assert_eq!(cont[0].direction, 1);
assert_relative_eq!(cont[0].open, 11.0, epsilon = 1e-12);
}
#[test]
fn small_pullback_prints_nothing() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // line 1
bars.update(flat(12.0)); // line 2
bars.update(flat(13.0)); // line 3, lows are 10/11/12
assert!(bars.update(flat(10.5)).is_empty()); // not > 13, not < 10
}
#[test]
fn reversal_breaks_three_lines() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0)); // line 1, low 10
bars.update(flat(12.0)); // line 2, low 11
bars.update(flat(13.0)); // line 3, low 12
let rev = bars.update(flat(9.0)); // 9 < lowest low 10 -> reverse
assert_eq!(rev.len(), 1);
assert_eq!(rev[0].direction, -1);
assert_relative_eq!(rev[0].open, 13.0, epsilon = 1e-12);
assert_relative_eq!(rev[0].close, 9.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
bars.update(flat(10.0));
bars.update(flat(11.0));
bars.reset();
assert_eq!(bars.tracked(), 0);
assert!(bars.update(flat(50.0)).is_empty()); // re-seeds
}
#[test]
fn flat_first_move_prints_nothing() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
assert!(bars.update(flat(10.0)).is_empty()); // seed
assert!(bars.update(flat(10.0)).is_empty()); // equal to seed -> no line
}
#[test]
fn first_line_down_then_down_trend_and_reversal() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
assert!(bars.update(flat(10.0)).is_empty()); // seed
let first = bars.update(flat(9.0)); // first line down
assert_eq!(first.len(), 1);
assert_eq!(first[0].direction, -1);
assert_relative_eq!(first[0].open, 10.0, epsilon = 1e-12);
assert_relative_eq!(first[0].close, 9.0, epsilon = 1e-12);
let cont = bars.update(flat(8.0)); // new low extends the down line
assert_eq!(cont.len(), 1);
assert_eq!(cont[0].direction, -1);
assert_relative_eq!(cont[0].open, 9.0, epsilon = 1e-12);
bars.update(flat(7.0)); // third down line; highs are 10/9/8
assert!(bars.update(flat(7.5)).is_empty()); // not < 7, not > highest high 10
let rev = bars.update(flat(11.0)); // > highest high 10 -> reverse up
assert_eq!(rev.len(), 1);
assert_eq!(rev[0].direction, 1);
assert_relative_eq!(rev[0].open, 7.0, epsilon = 1e-12);
}
#[test]
fn batch_concatenates_completed_lines() {
let mut bars = ThreeLineBreakBars::new(3).unwrap();
let candles = [flat(10.0), flat(11.0), flat(12.0), flat(13.0)];
let out = bars.batch(&candles);
// seed at 10, then three rising lines.
assert_eq!(out.len(), 3);
assert!(out.iter().all(|b| b.direction == 1));
}
}
@@ -0,0 +1,209 @@
//! Tick bar builder — aggregate a fixed number of candles into one OHLCV bar.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::BarBuilder;
/// One completed tick bar (an OHLCV aggregate of `ticks` input candles).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TickBar {
/// Open of the first candle in the group.
pub open: f64,
/// Highest high across the group.
pub high: f64,
/// Lowest low across the group.
pub low: f64,
/// Close of the last candle in the group.
pub close: f64,
/// Summed volume across the group.
pub volume: f64,
}
/// Tick bar builder — emits one OHLCV bar for every `ticks` input candles.
///
/// Classic time bars (1-minute, 1-hour) sample the market on a clock; tick bars
/// sample it on *activity* by grouping a fixed number of trades — here modelled as a
/// fixed number of input candles. In fast markets a tick bar closes quickly; in
/// quiet markets it takes longer, so each bar carries roughly equal information
/// content. This is the simplest of the information-driven bar types; the
/// [`VolumeBars`](crate::VolumeBars) and [`DollarBars`](crate::DollarBars) builders
/// extend the idea to equal traded volume and equal traded value respectively.
///
/// The open is the first candle's open, the high and low are the extremes across the
/// group, the close is the last candle's close, and the volume is the group sum.
/// Exactly one bar completes every `ticks` candles, so [`BarBuilder::update`]
/// returns either an empty vector or a single [`TickBar`].
///
/// # Example
///
/// ```
/// use wickra_core::{BarBuilder, Candle, TickBars};
///
/// let c = |o, h, l, cl, v| Candle::new(o, h, l, cl, v, 0).unwrap();
/// let mut bars = TickBars::new(3).unwrap();
/// assert!(bars.update(c(10.0, 11.0, 9.0, 10.5, 100.0)).is_empty());
/// assert!(bars.update(c(10.5, 12.0, 10.0, 11.0, 150.0)).is_empty());
/// let out = bars.update(c(11.0, 11.5, 10.8, 11.2, 120.0));
/// assert_eq!(out.len(), 1);
/// assert_eq!(out[0].volume, 370.0);
/// ```
#[derive(Debug, Clone)]
pub struct TickBars {
ticks: usize,
count: usize,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
}
impl TickBars {
/// Construct a tick-bar builder that groups `ticks` candles per bar.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `ticks == 0`.
pub fn new(ticks: usize) -> Result<Self> {
if ticks == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
ticks,
count: 0,
open: 0.0,
high: 0.0,
low: 0.0,
close: 0.0,
volume: 0.0,
})
}
/// Configured number of candles per bar.
pub const fn ticks(&self) -> usize {
self.ticks
}
/// Number of candles accumulated into the in-progress bar.
pub const fn count(&self) -> usize {
self.count
}
}
impl BarBuilder for TickBars {
type Bar = TickBar;
fn update(&mut self, candle: Candle) -> Vec<TickBar> {
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.count += 1;
if self.count < self.ticks {
return Vec::new();
}
self.count = 0;
vec![TickBar {
open: self.open,
high: self.high,
low: self.low,
close: self.close,
volume: self.volume,
}]
}
fn reset(&mut self) {
self.count = 0;
self.volume = 0.0;
}
fn name(&self) -> &'static str {
"TickBars"
}
}
#[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_zero_ticks() {
assert!(matches!(TickBars::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let bars = TickBars::new(5).unwrap();
assert_eq!(bars.ticks(), 5);
assert_eq!(bars.count(), 0);
assert_eq!(bars.name(), "TickBars");
}
#[test]
fn emits_every_n_candles() {
let mut bars = TickBars::new(2).unwrap();
assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).is_empty());
assert_eq!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).len(), 1);
assert!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).is_empty());
assert_eq!(bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0)).len(), 1);
}
#[test]
fn aggregates_ohlcv() {
let mut bars = TickBars::new(3).unwrap();
bars.update(candle(10.0, 11.0, 9.0, 10.5, 100.0));
bars.update(candle(10.5, 12.0, 10.0, 11.0, 150.0));
let out = bars.update(candle(11.0, 11.5, 10.8, 11.2, 120.0));
assert_eq!(out.len(), 1);
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.2, epsilon = 1e-12);
assert_relative_eq!(out[0].volume, 370.0, epsilon = 1e-12);
}
#[test]
fn partial_group_emits_nothing() {
let mut bars = TickBars::new(4).unwrap();
bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
assert_eq!(bars.count(), 2);
}
#[test]
fn reset_clears_state() {
let mut bars = TickBars::new(3).unwrap();
bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
bars.update(candle(10.0, 10.0, 10.0, 10.0, 1.0));
bars.reset();
assert_eq!(bars.count(), 0);
// After reset the next candle starts a fresh group.
assert!(bars.update(candle(20.0, 20.0, 20.0, 20.0, 5.0)).is_empty());
assert_eq!(bars.count(), 1);
}
#[test]
fn batch_concatenates_completed_bars() {
let mut bars = TickBars::new(2).unwrap();
let candles = [
candle(10.0, 10.0, 10.0, 10.0, 1.0),
candle(10.0, 10.0, 10.0, 10.0, 1.0),
candle(10.0, 10.0, 10.0, 10.0, 1.0),
candle(10.0, 10.0, 10.0, 10.0, 1.0),
];
let out = bars.batch(&candles);
assert_eq!(out.len(), 2);
}
}
@@ -0,0 +1,224 @@
#![allow(clippy::doc_markdown)]
//! Tower Top / Tower Bottom — a tall bar, a pause, then a tall opposite bar.
//!
//! A Tower is a reversal where a strong directional bar is followed by a small
//! "pause" bar and then a strong bar in the *opposite* direction, like two towers
//! flanking a low wall. This is the compact three-bar form of the classic
//! multi-bar Tower pattern.
//!
//! - **Tower Bottom** (`+1.0`): a tall **bearish** bar, a small-bodied bar, then a
//! tall **bullish** bar.
//! - **Tower Top** (`-1.0`): a tall **bullish** bar, a small-bodied bar, then a
//! tall **bearish** bar.
//! - Otherwise the output is `0.0`.
//!
//! "Tall" = body `>= 0.5 * range`; "small" = body `<= 0.3 * range`. The three-bar
//! lookback means the first value lands on the third candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
fn body_fraction(candle: Candle) -> f64 {
let range = candle.high - candle.low;
if range > 0.0 {
(candle.close - candle.open).abs() / range
} else {
0.0
}
}
fn is_tall(candle: Candle) -> bool {
body_fraction(candle) >= 0.5
}
fn is_small(candle: Candle) -> bool {
body_fraction(candle) <= 0.3
}
/// Tower Top / Bottom — three-bar reversal detector.
#[derive(Debug, Clone, Default)]
pub struct TowerTopBottom {
c1: Option<Candle>,
c2: Option<Candle>,
last_value: Option<f64>,
}
impl TowerTopBottom {
/// Construct a new `TowerTopBottom`.
#[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 TowerTopBottom {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let (Some(first), Some(middle)) = (self.c1, self.c2) else {
self.c1 = self.c2;
self.c2 = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let pause = is_small(middle);
let first_tall = is_tall(first);
let last_tall = is_tall(candle);
let v = if pause && first_tall && last_tall {
let first_up = first.close > first.open;
let last_up = candle.close > candle.open;
if !first_up && last_up {
1.0
} else if first_up && !last_up {
-1.0
} else {
0.0
}
} else {
0.0
};
self.c1 = self.c2;
self.c2 = Some(candle);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"TowerTopBottom"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
/// A tall candle from `open` to `close` (body fills most of the range).
fn tall(open: f64, close: f64) -> Candle {
Candle::new_unchecked(
open,
open.max(close) + 0.1,
open.min(close) - 0.1,
close,
0.0,
0,
)
}
/// A small-bodied candle (long shadows, tiny body).
fn small(mid: f64) -> Candle {
Candle::new_unchecked(mid, mid + 2.0, mid - 2.0, mid + 0.1, 0.0, 0)
}
#[test]
fn accessors_and_metadata() {
let t = TowerTopBottom::new();
assert_eq!(t.warmup_period(), 3);
assert_eq!(t.name(), "TowerTopBottom");
assert!(!t.is_ready());
assert_eq!(t.value(), None);
}
#[test]
fn first_two_bars_seed_without_signal() {
let mut t = TowerTopBottom::new();
assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0));
assert_eq!(t.update(small(105.0)), Some(0.0));
assert!(t.update(tall(110.0, 100.0)).is_some());
}
#[test]
fn tower_top() {
// tall bullish, small pause, tall bearish -> top -> -1.
let mut t = TowerTopBottom::new();
t.update(tall(100.0, 110.0));
t.update(small(110.0));
assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
}
#[test]
fn tower_bottom() {
let mut t = TowerTopBottom::new();
t.update(tall(110.0, 100.0));
t.update(small(100.0));
assert_eq!(t.update(tall(100.0, 110.0)), Some(1.0));
}
#[test]
fn same_direction_is_zero() {
let mut t = TowerTopBottom::new();
t.update(tall(100.0, 110.0));
t.update(small(110.0));
// last bar also bullish -> not a tower -> 0.
assert_eq!(t.update(tall(110.0, 120.0)), Some(0.0));
}
#[test]
fn no_pause_is_zero() {
let mut t = TowerTopBottom::new();
t.update(tall(100.0, 110.0));
t.update(tall(110.0, 120.0)); // middle is tall, not a pause
assert_eq!(t.update(tall(120.0, 110.0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut t = TowerTopBottom::new();
t.update(tall(100.0, 110.0));
t.update(small(110.0));
t.update(tall(110.0, 100.0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(tall(100.0, 110.0)), Some(0.0));
}
#[test]
fn zero_range_bar_has_zero_body_fraction() {
// A flat bar (high == low) exercises the zero-range body-fraction branch;
// it counts as a small "pause" bar, so tall-flat-tall still reverses.
fn flat(mid: f64) -> Candle {
Candle::new_unchecked(mid, mid, mid, mid, 0.0, 0)
}
let mut t = TowerTopBottom::new();
t.update(tall(100.0, 110.0));
t.update(flat(110.0));
assert_eq!(t.update(tall(110.0, 100.0)), Some(-1.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..30)
.map(|i| match i % 3 {
0 => tall(100.0, 110.0),
1 => small(110.0),
_ => tall(110.0, 100.0),
})
.collect();
let batch = TowerTopBottom::new().batch(&candles);
let mut b = TowerTopBottom::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,217 @@
//! Trade-Sign Autocorrelation — lag-1 persistence of the trade-aggressor side.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::microstructure::Trade;
use crate::traits::Indicator;
/// Trade-Sign Autocorrelation — the lag-1 autocorrelation of the **trade sign**
/// (`+1` buy, `1` sell), measuring how strongly signed order flow persists.
///
/// ```text
/// s_t = +1 if the trade is a buy, 1 if a sell
/// ρ1 = mean over the window of ( s_t · s_{t1} ) ∈ [1, +1]
/// ```
///
/// In real markets trade signs are strongly **positively** autocorrelated: a buy
/// tends to be followed by another buy (and a sell by a sell), because large
/// parent orders are split into many child trades and because of order-splitting
/// and herding. A high reading therefore indicates persistent directional pressure
/// — a footprint of informed or algorithmic execution — while a reading near zero
/// signals balanced, uninformed flow and a negative reading signals alternating
/// (bid-ask bounce) flow.
///
/// The output is the mean product of consecutive signs, bounded in `[1, +1]`. The
/// first value lands after `period` trades. Each `update` is O(`period`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Side, Trade, TradeSignAutocorrelation};
///
/// let mut indicator = TradeSignAutocorrelation::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
/// last = indicator.update(Trade::new(100.0, 1.0, side, i).unwrap());
/// }
/// // Perfectly alternating signs -> autocorrelation -1.
/// assert!((last.unwrap() + 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct TradeSignAutocorrelation {
period: usize,
signs: VecDeque<f64>,
last: Option<f64>,
}
impl TradeSignAutocorrelation {
/// Construct a trade-sign autocorrelation over `period` trades.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a lag-1 product needs two
/// trades).
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "trade-sign autocorrelation needs period >= 2",
});
}
Ok(Self {
period,
signs: VecDeque::with_capacity(period),
last: None,
})
}
/// Configured window of trades.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for TradeSignAutocorrelation {
type Input = Trade;
type Output = f64;
fn update(&mut self, trade: Trade) -> Option<f64> {
if self.signs.len() == self.period {
self.signs.pop_front();
}
self.signs.push_back(trade.side.sign());
if self.signs.len() < self.period {
return None;
}
let mut product_sum = 0.0;
let mut prev: Option<f64> = None;
for &s in &self.signs {
if let Some(p) = prev {
product_sum += s * p;
}
prev = Some(s);
}
let rho = product_sum / (self.period as f64 - 1.0);
self.last = Some(rho);
Some(rho)
}
fn reset(&mut self) {
self.signs.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 {
"TradeSignAutocorrelation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::microstructure::Side;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn buy() -> Trade {
Trade::new_unchecked(100.0, 1.0, Side::Buy, 0)
}
fn sell() -> Trade {
Trade::new_unchecked(100.0, 1.0, Side::Sell, 0)
}
#[test]
fn rejects_period_below_two() {
assert!(matches!(
TradeSignAutocorrelation::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(TradeSignAutocorrelation::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let t = TradeSignAutocorrelation::new(20).unwrap();
assert_eq!(t.period(), 20);
assert_eq!(t.warmup_period(), 20);
assert_eq!(t.name(), "TradeSignAutocorrelation");
assert!(!t.is_ready());
assert_eq!(t.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut t = TradeSignAutocorrelation::new(4).unwrap();
let out = t.batch(&[buy(), buy(), buy(), buy(), buy()]);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn persistent_flow_is_one() {
let mut t = TradeSignAutocorrelation::new(10).unwrap();
let trades: Vec<Trade> = (0..20).map(|_| buy()).collect();
let last = t.batch(&trades).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-12);
}
#[test]
fn alternating_flow_is_minus_one() {
let mut t = TradeSignAutocorrelation::new(10).unwrap();
let trades: Vec<Trade> = (0..20)
.map(|i| if i % 2 == 0 { buy() } else { sell() })
.collect();
let last = t.batch(&trades).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let mut t = TradeSignAutocorrelation::new(16).unwrap();
let trades: Vec<Trade> = (0..200)
.map(|i| if (i * 7 % 13) < 6 { buy() } else { sell() })
.collect();
for v in t.batch(&trades).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v));
}
}
#[test]
fn reset_clears_state() {
let mut t = TradeSignAutocorrelation::new(4).unwrap();
t.batch(&[buy(), buy(), buy(), buy()]);
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.value(), None);
assert_eq!(t.update(buy()), None);
}
#[test]
fn batch_equals_streaming() {
let trades: Vec<Trade> = (0..120)
.map(|i| if i % 3 == 0 { sell() } else { buy() })
.collect();
let batch = TradeSignAutocorrelation::new(16).unwrap().batch(&trades);
let mut b = TradeSignAutocorrelation::new(16).unwrap();
let streamed: Vec<_> = trades.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,189 @@
#![allow(clippy::doc_markdown)]
//! Tristar — a three-doji reversal pattern.
//!
//! A Tristar is three consecutive Doji candles where the middle one gaps away
//! from its neighbours, forming a star. A bearish Tristar (top) has the middle
//! doji sitting above the other two; a bullish Tristar (bottom) has it below.
//!
//! - **Bullish** (`+1.0`): three dojis, the middle doji's body centre below both
//! neighbours' body centres.
//! - **Bearish** (`-1.0`): three dojis, the middle above both neighbours.
//! - Otherwise the output is `0.0`.
//!
//! A doji is a candle whose body is `<= 0.1 * range`. The three-bar lookback means
//! the first value lands on the third candle.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Body-centre of a candle.
fn body_mid(candle: Candle) -> f64 {
f64::midpoint(candle.open, candle.close)
}
/// Whether a candle is a doji (body small relative to range).
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
}
/// Tristar — three-doji star reversal detector.
#[derive(Debug, Clone, Default)]
pub struct Tristar {
c1: Option<Candle>,
c2: Option<Candle>,
last_value: Option<f64>,
}
impl Tristar {
/// Construct a new `Tristar`.
#[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 Tristar {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let (Some(first), Some(middle)) = (self.c1, self.c2) else {
self.c1 = self.c2;
self.c2 = Some(candle);
self.last_value = Some(0.0);
return Some(0.0);
};
let v = if is_doji(first) && is_doji(middle) && is_doji(candle) {
let mid = body_mid(middle);
let n1 = body_mid(first);
let n3 = body_mid(candle);
if mid > n1 && mid > n3 {
-1.0
} else if mid < n1 && mid < n3 {
1.0
} else {
0.0
}
} else {
0.0
};
self.c1 = self.c2;
self.c2 = Some(candle);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
3
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"Tristar"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
/// A doji centred at `mid` (tiny body, symmetric shadows).
fn doji(mid: f64) -> Candle {
Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
}
/// A non-doji (big body).
fn solid(open: f64, close: f64) -> Candle {
Candle::new_unchecked(
open,
open.max(close) + 0.1,
open.min(close) - 0.1,
close,
0.0,
0,
)
}
#[test]
fn accessors_and_metadata() {
let t = Tristar::new();
assert_eq!(t.warmup_period(), 3);
assert_eq!(t.name(), "Tristar");
assert!(!t.is_ready());
assert_eq!(t.value(), None);
}
#[test]
fn first_two_bars_seed_without_signal() {
let mut t = Tristar::new();
assert_eq!(t.update(doji(100.0)), Some(0.0));
assert_eq!(t.update(doji(100.0)), Some(0.0));
assert!(t.update(doji(100.0)).is_some());
}
#[test]
fn bearish_tristar_top() {
// middle doji centred above the two neighbours -> top -> -1.
let mut t = Tristar::new();
t.update(doji(100.0));
t.update(doji(105.0)); // middle, highest
assert_eq!(t.update(doji(100.0)), Some(-1.0));
}
#[test]
fn bullish_tristar_bottom() {
let mut t = Tristar::new();
t.update(doji(100.0));
t.update(doji(95.0)); // middle, lowest
assert_eq!(t.update(doji(100.0)), Some(1.0));
}
#[test]
fn non_doji_is_zero() {
let mut t = Tristar::new();
t.update(doji(100.0));
t.update(solid(100.0, 110.0)); // not a doji
assert_eq!(t.update(doji(100.0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut t = Tristar::new();
t.update(doji(100.0));
t.update(doji(105.0));
t.update(doji(100.0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(doji(100.0)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| doji(100.0 + (f64::from(i) * 0.4).sin() * 5.0))
.collect();
let batch = Tristar::new().batch(&candles);
let mut b = Tristar::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,226 @@
//! Upside Potential Ratio (Sortino, van der Meer & Plantinga) — upside mean over downside deviation.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Upside Potential Ratio over a trailing window of `period` returns, measured
/// relative to a minimal acceptable return (`mar`).
///
/// ```text
/// upside = mean( max(r mar, 0) ) over the window
/// downside = sqrt( mean( min(r mar, 0)² ) ) over the window
/// UPR = upside / downside
/// ```
///
/// Where the [`SharpeRatio`](crate::SharpeRatio) divides excess return by *total*
/// volatility (penalising upside and downside symmetrically), the Upside Potential
/// Ratio rewards only the average outperformance above the threshold while
/// penalising solely the downside deviation below it. It is the purest expression
/// of the Sortino philosophy: investors do not dislike upside variance, only
/// shortfall risk.
///
/// `mar` (minimal acceptable return) is the per-period hurdle the caller supplies
/// (e.g. `0.0` for break-even, or a target rate matching the return frequency). A
/// window that never breaches the threshold has zero downside deviation; the
/// indicator then reports `0.0` rather than dividing by zero.
///
/// Each `update` is O(1) — running sums maintain the upside total and the
/// downside sum-of-squares as the window slides.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, UpsidePotentialRatio};
///
/// let mut indicator = UpsidePotentialRatio::new(20, 0.0).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 UpsidePotentialRatio {
period: usize,
mar: f64,
window: VecDeque<f64>,
sum_upside: f64,
sum_downside_sq: f64,
}
impl UpsidePotentialRatio {
/// Construct an Upside Potential Ratio over `period` returns with minimal
/// acceptable return `mar`.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or
/// [`Error::InvalidParameter`] if `mar` is not finite.
pub fn new(period: usize, mar: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "upside potential ratio needs period >= 2",
});
}
if !mar.is_finite() {
return Err(Error::InvalidParameter {
message: "mar must be finite",
});
}
Ok(Self {
period,
mar,
window: VecDeque::with_capacity(period),
sum_upside: 0.0,
sum_downside_sq: 0.0,
})
}
/// Configured window of returns.
pub const fn period(&self) -> usize {
self.period
}
/// Configured minimal acceptable return.
pub const fn mar(&self) -> f64 {
self.mar
}
}
impl Indicator for UpsidePotentialRatio {
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 {
let old = self.window.pop_front().expect("non-empty");
let excess = old - self.mar;
self.sum_upside -= excess.max(0.0);
self.sum_downside_sq -= excess.min(0.0).powi(2);
}
let excess = ret - self.mar;
self.sum_upside += excess.max(0.0);
self.sum_downside_sq += excess.min(0.0).powi(2);
self.window.push_back(ret);
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let upside_mean = self.sum_upside / n;
let downside_dev = (self.sum_downside_sq / n).sqrt();
if downside_dev > 0.0 {
Some(upside_mean / downside_dev)
} else {
Some(0.0)
}
}
fn reset(&mut self) {
self.window.clear();
self.sum_upside = 0.0;
self.sum_downside_sq = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"UpsidePotentialRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_less_than_two() {
assert!(matches!(
UpsidePotentialRatio::new(1, 0.0),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn rejects_non_finite_mar() {
assert!(matches!(
UpsidePotentialRatio::new(10, f64::NAN),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let upr = UpsidePotentialRatio::new(20, 0.001).unwrap();
assert_eq!(upr.period(), 20);
assert_relative_eq!(upr.mar(), 0.001, epsilon = 1e-12);
assert_eq!(upr.warmup_period(), 20);
assert_eq!(upr.name(), "UpsidePotentialRatio");
}
#[test]
fn reference_value() {
// returns [0.02, -0.01, 0.03, -0.02], mar = 0.
// upside = (0.02 + 0 + 0.03 + 0)/4 = 0.0125.
// downside = sqrt((0 + 0.0001 + 0 + 0.0004)/4) = sqrt(0.000125).
// UPR = 0.0125 / sqrt(0.000125).
let mut upr = UpsidePotentialRatio::new(4, 0.0).unwrap();
let out = upr.batch(&[0.02, -0.01, 0.03, -0.02]);
let expected = 0.0125_f64 / (0.000_125_f64).sqrt();
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
}
#[test]
fn no_downside_is_zero() {
let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
let last = upr
.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_input() {
let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
assert_eq!(upr.update(0.01), None);
assert_eq!(upr.update(f64::INFINITY), None);
assert_eq!(upr.update(-0.02), None);
assert!(upr.update(0.03).is_some());
}
#[test]
fn reset_clears_state() {
let mut upr = UpsidePotentialRatio::new(2, 0.0).unwrap();
upr.batch(&[0.02, -0.01]);
assert!(upr.is_ready());
upr.reset();
assert!(!upr.is_ready());
assert_eq!(upr.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 = UpsidePotentialRatio::new(12, 0.0).unwrap().batch(&rets);
let mut streamer = UpsidePotentialRatio::new(12, 0.0).unwrap();
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
assert_eq!(batch, streamed);
}
}

Some files were not shown because too many files have changed in this diff Show More