Compare commits

..

5 Commits

Author SHA1 Message Date
kingchenc c44f625e69 release: bump 0.4.6 -> 0.4.7 (#156)
Routine patch release. Ships the 10 pairwise stat-arb indicators added to Price Statistics in #154 (Rolling Correlation, Rolling Covariance, OU Half-Life, Kalman Hedge Ratio, Variance Ratio, Spread Bollinger Bands, Spread Hurst, Distance SSD, Granger Causality, Beta-Neutral Spread) together with the new Market Breadth family and its `CrossSection` input type (AdvanceDecline).

Version strings bumped `0.4.6 -> 0.4.7` across:
- `Cargo.toml` (workspace + `wickra-core` dep), `Cargo.lock`
- `bindings/python/pyproject.toml`
- `bindings/node/package.json` (+ 6 optional platform deps) and the 6 `npm/<platform>/package.json`
- `bindings/node/package-lock.json`, `examples/node/package-lock.json`
- `CHANGELOG.md` — `[Unreleased]` rolled into `[0.4.7] - 2026-06-03` with refreshed compare links

No code changes. `fmt` / `test --workspace` / `clippy --workspace -D warnings` green locally.
2026-06-03 16:02:30 +02:00
kingchenc 46dc8f5a00 ci(sync-about): read-only PR counter check, no bot fix-up push (#155)
## Problem
On every indicator PR the `sync-about` workflow found `docs/README.md` lagging `lib.rs` (the wiring only bumped `README.md`) and pushed a `wickra-bot` *"sync indicator count"* commit onto the PR head. That push uses `GITHUB_TOKEN`, which **triggers no workflows**, so it moved the PR head onto a commit with no CI run — and the **Codecov patch status** (keyed to the PR head sha) stopped surfacing on the PR.

## Fix
- The indicator wiring (`ScriptHelpers/_common.py` `wire_readme_counter`) now bumps **both** `README.md` and `docs/README.md` in the author's code commit, so the counter is already correct when CI runs.
- This workflow's PR flow is reduced to a **read-only check** that fails loud (fork and same-repo PRs alike) if either counter is stale, and **never pushes**.
- The `GITHUB_TOKEN` job permission drops from `contents: write` back to `read` (OpenSSF Scorecard: Token-Permissions). The removed `ctx` step + push steps are gone.
- The `main`/tag outward syncs (About description, docs/webpage/wiki/org) are **unchanged** — they use the `ABOUT_SYNC_TOKEN` PAT, not `GITHUB_TOKEN`.

## Effect
Indicator PRs keep their head on the code commit → the Codecov patch status surfaces again. No functional change to merged-main state (the counts still land, now inside the squash-merged code commit).
2026-06-03 15:40:24 +02:00
kingchenc a3a1ae4dba Add 10 pairwise stat-arb indicators to Price Statistics (#154)
Adds ten pairwise `(f64, f64)` indicators to the **Price Statistics** family, completing the A1 stat-arb expansion block.

## Indicators

**Scalar output:**
- **RollingCorrelation** — rolling Pearson correlation of period-over-period *returns* (distinct from level-based `PearsonCorrelation`).
- **RollingCovariance** — rolling covariance of returns.
- **OuHalfLife** — Ornstein–Uhlenbeck half-life of mean reversion of the spread `a − b`.
- **SpreadHurst** — Hurst exponent of the spread (variance-of-lagged-differences fit) for regime detection.
- **DistanceSsd** — Gatev sum-of-squared-deviations between two start-normalised series.
- **BetaNeutralSpread** — rolling OLS regression residual `a − (α + β·b)`.
- **VarianceRatio** — Lo–MacKinlay variance-ratio test on the spread (two params: `period`, `q`).
- **GrangerCausality** — F-statistic for whether `b` predicts `a` (two params: `period`, `lag`).

**Struct output (custom bindings):**
- **KalmanHedgeRatio** — dynamic hedge ratio via a Kalman filter → `{ hedgeRatio, intercept, spread }`.
- **SpreadBollingerBands** — Bollinger bands on the spread → `{ middle, upper, lower, percentB }`.

## Notes
- No new traits or input families: all use the native `Indicator<Input = (f64, f64)>` (precedent `Beta`, `Cointegration`).
- Adds `Error::InvalidParameter` for floating-point constructor parameters (Kalman `delta`/`observation_var`, `num_std`).
- Full Python/Node/WASM bindings; the two struct-output indicators are hand-written, the rest use the pair macros.
- Indicator count 315 → 325; README, family rows, `__init__`, fuzz target, and CHANGELOG updated.

## Verification
- `cargo test --workspace --all-features` — green (2676 core lib + 308 doc).
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` — clean.
- Node: `npm run build && npm test` — 410 passing (`index.d.ts`/`index.js` regenerated).
- Python: `pytest` — 684 passing.
2026-06-03 15:39:55 +02:00
kingchenc 53941b7b07 feat: add Market Breadth family with CrossSection input (#153)
## What

Adds a new indicator input type and family for **market-breadth** analysis — indicators that aggregate the state of an entire universe of symbols at each tick, rather than a single instrument's price. This is the last open input-type on the expansion roadmap (S10) and unblocks the remaining breadth indicators (McClellan, TRIN, High-Low Index, ...).

## Core

- **`CrossSection` input type** (`crates/wickra-core/src/cross_section.rs`) — one tick carrying the per-symbol state of the whole universe as a `Vec<Member>` + `timestamp`. Each `Member` precomputes a signed `change` (sign classifies advancing / declining / unchanged), a `volume`, and `new_high` / `new_low` extreme flags, so the breadth indicators stay stateless per tick. Both `Member` and `CrossSection` are `#[non_exhaustive]` for additive field growth. `CrossSection::new` validates the universe (non-empty, finite changes, finite non-negative volumes); `new_unchecked` skips validation for hot paths. `advancers()` / `decliners()` count by sign.
- **`Error::InvalidCrossSection`** variant for the validation failures.
- **`AdvanceDecline`** (`advance_decline.rs`) — the Advance/Decline Line: the running cumulative sum of net advancing-minus-declining issues. `Input = CrossSection`, `Output = f64`, ready after the first tick.
- New **"Market Breadth"** `FAMILIES` group; indicator count **314 → 315**, family count nineteen → twenty.

## Bindings

All custom (CrossSection is non-scalar, so no macros apply). The universe crosses each boundary as parallel arrays (`change`, `volume`, `new_high`, `new_low`):
- **Python / Node** expose `update` + `batch` (one array group per tick). Node satisfies the completeness contract (`update`/`batch`/`reset`/`isReady`/`warmupPeriod`).
- **WASM** exposes only `update` (the universe is ragged across ticks, matching the other multi-input wasm indicators) with numeric high/low flags.
- Python `map_err` gains the new error arm; `__init__.py` gets a `# Market Breadth` section in both the import and `__all__` blocks. `index.d.ts` / `index.js` regenerated.

## Tests / Fuzz

- Dedicated **streaming-vs-batch + reference-value + ragged-rejection** tests in Python (`test_new_indicators.py`) and Node (`indicators.test.js`) — kept out of the scalar/candle parametrize lists.
- Rust unit tests cover every reject branch (empty / non-finite change / negative & non-finite volume) and every indicator branch.
- New fuzz target `indicator_update_crosssection` drives `AdvanceDecline` over bounded ragged universes built with `new_unchecked`.

## Verify

- `cargo fmt --all` clean
- `cargo test -p wickra-core --lib` → 2593 passed; `--doc` → 298 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` clean
- `cd bindings/node && npm run build && npm test` → 398 passed
- `maturin develop --release` + `pytest bindings/python/tests` → all passed
- counter check: mod-count 315 == lib-block 315
2026-06-03 04:11:10 +02:00
kingchenc 72ec65bbde fix: classify pairwise indicators into Price Statistics family (#152)
## What

The `FAMILIES` table in `crates/wickra-core/src/indicators/mod.rs` had drifted from the indicator count: `mod`-count was **314** but the FAMILIES total asserted **309**.

The five pairwise indicators `Cointegration`, `LeadLagCrossCorrelation`, `PairSpreadZScore`, `PairwiseBeta` and `RelativeStrengthAB` were exported via `pub use` but never assigned to a `FAMILIES` group — even though the README and docs already list them under **Price Statistics**. The "−5 offset" was therefore unclassified drift, not an intentional cross-asset offset.

## Change

- Add the five indicators to the `Price Statistics` group, next to the existing pairwise cluster (`PearsonCorrelation` / `Beta` / `SpearmanCorrelation`).
- Bump the drift assert `309 → 314` so the FAMILIES total now equals the `mod`-count exactly (offset 0).

No new indicators, no binding or doc changes — purely re-classification. The `mod`-count stays 314, so no counter bump.

## Verify

- `cargo fmt --all` clean
- `cargo test -p wickra-core --lib` → 2578 passed (incl. the FAMILIES drift test)
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` clean
2026-06-03 03:38:14 +02:00
42 changed files with 5227 additions and 207 deletions
+40 -105
View File
@@ -41,17 +41,17 @@ name: Sync indicator count
# `RollingVwap`, so the mod-count under-reports by one. lib.rs is the
# single source of truth for what the bindings reach.
#
# Design: keep README in sync *before* a PR is merged, by pushing a
# fix-up commit to the PR head branch. After squash-merge into main
# the bot commit is folded into the single signed merge commit, so
# main's history never shows an unsigned "sync indicator count" entry.
# 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.
#
# The push to PR head uses the default `GITHUB_TOKEN`, whose pushes
# explicitly do NOT trigger downstream workflows (anti-recursion
# policy). So a counter fix-up does not re-trigger ci.yml on the PR
# — it does, however, re-trigger sync-about.yml on the next PR
# `synchronize` event, which is what we want (a no-op if the counter
# is now correct).
# (An earlier version pushed a GITHUB_TOKEN "sync indicator count" commit to
# the PR head. Because GITHUB_TOKEN pushes trigger no workflows, that commit
# moved the PR head onto a commit with no CI run, which hid the Codecov patch
# status — keyed to the PR head sha — from the PR. Keeping the counter in the
# code commit avoids that entirely.)
on:
push:
branches: [main]
@@ -73,53 +73,24 @@ permissions:
jobs:
sync:
runs-on: ubuntu-latest
# The only GITHUB_TOKEN write in this workflow: pushing the counter fix-up
# commit onto a same-repo PR head branch (git push origin HEAD:<ref>).
# This workflow never writes to wickra-lib/wickra with GITHUB_TOKEN: the PR
# flow is a read-only check, and the main/tag flow writes only to other
# repos (About metadata, docs, webpage, wiki, org) through the fine-grained
# ABOUT_SYNC_TOKEN PAT. So GITHUB_TOKEN stays read-only (OpenSSF Scorecard:
# Token-Permissions).
permissions:
contents: write
contents: read
pull-requests: read
steps:
# On PRs from forks the head ref lives in another repo; pushing
# back to it from this workflow is blocked by GitHub. We still
# want the PR to surface the missing counter, so the check below
# falls back to a hard failure when push isn't possible.
- name: Determine if push to PR head is possible
id: ctx
# Untrusted PR contexts (head.ref / head.repo.full_name are attacker
# controlled on fork PRs) are passed through the environment, never
# interpolated straight into the shell, so a crafted branch name cannot
# inject commands (OpenSSF Scorecard: Dangerous-Workflow).
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
if [ "$HEAD_REPO" = "$BASE_REPO" ]; then
echo "can_push=true" >> "$GITHUB_OUTPUT"
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
else
echo "can_push=false" >> "$GITHUB_OUTPUT"
echo "head_ref=" >> "$GITHUB_OUTPUT"
fi
else
echo "can_push=false" >> "$GITHUB_OUTPUT"
echo "head_ref=" >> "$GITHUB_OUTPUT"
fi
# On PRs we check out the *head* commit (not the merge ref) so
# any fix-up commit we make goes onto the PR branch itself. On
# push events we check out the default ref. fetch-depth: 0 lets
# us push back without "shallow update not allowed".
# On PRs we check out the PR *head* commit (the author's code, not the
# merge ref) so the counter check validates exactly what will land. On
# push events we check out the default ref. No push is made, so a shallow
# checkout is enough.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-depth: 1
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
# Default GITHUB_TOKEN is fine for the same-repo PR-branch
# push; the About / Wiki steps re-authenticate with the PAT
# below where needed.
- name: Count indicators
id: count
@@ -139,69 +110,33 @@ jobs:
# ----- PR flow ---------------------------------------------------
- name: Check README counter (PR)
- name: Check README counter (PR, read-only)
if: github.event_name == 'pull_request'
id: pr_check
run: |
n="${{ steps.count.outputs.count }}"
if grep -qE "^${n} streaming-first indicators" README.md \
&& grep -qE "\*\*${n} indicators\*\*" docs/README.md; then
echo "matches=true" >> "$GITHUB_OUTPUT"
echo "README + docs/README counter already at ${n}; nothing to do."
else
echo "matches=false" >> "$GITHUB_OUTPUT"
echo "README/docs counter does not match ${n}; will fix up."
ok=true
if ! grep -qE "^${n} streaming-first indicators" README.md; then
echo "::error::README.md does not say '${n} streaming-first indicators' — lib.rs exports ${n}. Re-run the indicator wiring (it bumps README.md), then push again."
ok=false
fi
- name: Fix counter on fork PR head (read-only, fail loud)
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'false'
run: |
n="${{ steps.count.outputs.count }}"
echo "::error::README.md / docs/README.md say a different indicator count than lib.rs (${n}). This PR is from a fork, so the workflow cannot push the fix; please set README.md to '${n} streaming-first indicators' and docs/README.md to '**${n} indicators**', then push again."
exit 1
- name: Patch README on PR head
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'true'
id: pr_patch
run: |
n="${{ steps.count.outputs.count }}"
# docs/README.md carries the count in its docs.wickra.org pointer prose
# ("**N indicators**"); keep it in sync with README's prose count.
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" README.md docs/README.md
# Bump the banner cache-buster so GitHub's Camo proxy refetches the org
# profile image (regenerated with the new count by .github/banner.yml)
# instead of serving a stale cached copy. (README only — docs has no banner.)
sed -i -E "s|(wickra-banner\.webp\?v=)[0-9]+|\1${n}|" README.md
if git diff --quiet; then
echo "No README changes after sed (counter regex did not match anything); skipping push."
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
if ! grep -qE "\*\*${n} indicators\*\*" docs/README.md; then
echo "::error::docs/README.md does not say '**${n} indicators**' — lib.rs exports ${n}. Re-run the indicator wiring (it bumps docs/README.md), then push again."
ok=false
fi
if [ "$ok" = "true" ]; then
echo "README.md + docs/README.md counter already at ${n}; nothing to do."
else
exit 1
fi
- name: Commit & push counter fix to PR head
if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
# head_ref still carries the (untrusted) PR branch name forwarded by the
# ctx step; pass it through the environment so the push refspec cannot be
# used to inject shell commands (OpenSSF Scorecard: Dangerous-Workflow).
env:
COUNT: ${{ steps.count.outputs.count }}
HEAD_REF: ${{ steps.ctx.outputs.head_ref }}
run: |
git config user.name "wickra-bot"
git config user.email "wickra-bot@users.noreply.github.com"
git add README.md docs/README.md
git commit -m "chore: sync indicator count to ${COUNT}"
git push origin "HEAD:${HEAD_REF}"
# ----- main / tag flow ------------------------------------------
#
# After a PR squash-merges, this workflow runs again on the push
# to main. README is already correct (it was fixed on the PR
# branch before the merge); the only outward syncs left are the
# GitHub About description (repo metadata, not a commit) and the
# wiki repo (separate repo, no main history pollution). README is
# not touched on main any more.
# After a PR squash-merges, this workflow runs again on the push to main.
# README.md / docs/README.md are already correct (the indicator wiring
# bumped them in the merged code commit); the only outward syncs left are
# the GitHub About description (repo metadata, not a commit) and the docs /
# webpage / wiki / org repos (separate repos, no main history pollution).
# The wickra repo's own README is not touched on main any more.
- name: Update GitHub About (description + homepage)
if: github.event_name != 'pull_request'
+25 -1
View File
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.7] - 2026-06-03
### Added
- **Spread Bollinger Bands** — Bollinger bands on the spread of two series for pairs mean-reversion (`SPREAD_BOLLINGER_BANDS`).
- **Kalman Hedge Ratio** — Kalman-filter dynamic hedge ratio and spread between two series (`KALMAN_HEDGE_RATIO`).
- **Granger Causality** — Granger causality F-statistic measuring whether one series predicts another (`GRANGER_CAUSALITY`).
- **Variance Ratio** — Lo-MacKinlay variance-ratio test on the spread of two series (`VARIANCE_RATIO`).
- **Beta-Neutral Spread** — beta-neutral spread: the rolling OLS regression residual of two series (`BETA_NEUTRAL_SPREAD`).
- **Distance SSD** — Gatev sum-of-squared-deviations distance between two normalised series (`DISTANCE_SSD`).
- **Spread Hurst** — Hurst exponent of the spread of two series for regime detection (`SPREAD_HURST`).
- **OU Half-Life** — Ornstein-Uhlenbeck half-life of mean reversion for the spread of two series (`OU_HALF_LIFE`).
- **Rolling Covariance** — rolling covariance of the period-over-period returns of two series (`ROLLING_COVARIANCE`).
- **Rolling Correlation** — rolling Pearson correlation of the period-over-period returns of two series (`ROLLING_CORRELATION`).
- **Market Breadth family** — a new indicator family built on a new
`CrossSection` input type that carries the per-symbol state of an entire
universe in one tick (each `Member` holds a signed `change`, a `volume`, and
`new_high` / `new_low` flags). `CrossSection::new` validates the universe
(non-empty, finite changes, finite non-negative volumes); `new_unchecked`
skips validation for hot paths.
- `AdvanceDecline` (`ADVANCE_DECLINE`) — the Advance/Decline Line, the running
cumulative sum of net advancing-minus-declining issues across the universe.
## [0.4.6] - 2026-06-03
### Added
@@ -1124,7 +1147,8 @@ 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.4.6...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.4.7...HEAD
[0.4.7]: https://github.com/wickra-lib/wickra/compare/v0.4.6...v0.4.7
[0.4.6]: https://github.com/wickra-lib/wickra/compare/v0.4.5...v0.4.6
[0.4.5]: https://github.com/wickra-lib/wickra/compare/v0.4.4...v0.4.5
[0.4.4]: https://github.com/wickra-lib/wickra/compare/v0.4.3...v0.4.4
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.4.6"
version = "0.4.7"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.4.6"
version = "0.4.7"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.4.6"
version = "0.4.7"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.4.6"
version = "0.4.7"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.4.6"
version = "0.4.7"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.4.6"
version = "0.4.7"
dependencies = [
"console_error_panic_hook",
"js-sys",
+2 -2
View File
@@ -12,7 +12,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.4.6"
version = "0.4.7"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.4.6" }
wickra-core = { path = "crates/wickra-core", version = "0.4.7" }
thiserror = "2"
rayon = "1.10"
+6 -5
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=314" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=325" 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)
@@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
every one of the 314 indicators; start at the
every one of the 325 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),
@@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries
## Indicators
314 streaming-first indicators across nineteen families. Every one passes the
325 streaming-first indicators across twenty 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).
@@ -150,7 +150,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
@@ -160,6 +160,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint |
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread |
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
| Market Breadth | Advance/Decline Line |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
@@ -239,7 +240,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 314 indicators
│ ├── wickra-core/ core engine + all 325 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -529,6 +529,14 @@ const pairFactories = {
PairwiseBeta: () => new wickra.PairwiseBeta(14),
PairSpreadZScore: () => new wickra.PairSpreadZScore(14, 14),
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
RollingCorrelation: () => new wickra.RollingCorrelation(20),
RollingCovariance: () => new wickra.RollingCovariance(20),
OuHalfLife: () => new wickra.OuHalfLife(60),
SpreadHurst: () => new wickra.SpreadHurst(60),
DistanceSsd: () => new wickra.DistanceSsd(20),
BetaNeutralSpread: () => new wickra.BetaNeutralSpread(20),
VarianceRatio: () => new wickra.VarianceRatio(60, 2),
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
};
for (const [name, make] of Object.entries(pairFactories)) {
@@ -619,6 +627,47 @@ test('Cointegration batch is flat 3*n with last row matching', () => {
assert.ok(out[3 * (n - 1) + 2] < -2);
});
test('KalmanHedgeRatio converges to a static hedge ratio (object output)', () => {
const n = 500;
const b = Array.from({ length: n }, (_, t) => 100 + 95 * Math.sin(t * 0.5));
const a = b.map((v) => 2 * v + 5);
const k = new wickra.KalmanHedgeRatio(1e-2, 1e-3);
let last = null;
for (let i = 0; i < n; i++) last = k.update(a[i], b[i]);
assert.ok(Math.abs(last.hedgeRatio - 2) < 0.05);
assert.ok(Math.abs(last.spread) < 0.05);
});
test('KalmanHedgeRatio batch is flat 3*n with last row matching', () => {
const n = 500;
const b = Array.from({ length: n }, (_, t) => 100 + 95 * Math.sin(t * 0.5));
const a = b.map((v) => 2 * v + 5);
const out = new wickra.KalmanHedgeRatio(1e-2, 1e-3).batch(a, b);
assert.equal(out.length, 3 * n);
assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 0.05);
assert.ok(Math.abs(out[3 * (n - 1) + 2]) < 0.05);
});
test('SpreadBollingerBands bands are ordered (object output)', () => {
const n = 60;
const b = Array.from({ length: n }, (_, t) => 100 + t);
const a = b.map((v, t) => v + 3 * Math.sin(t * 0.4));
const bb = new wickra.SpreadBollingerBands(20, 2.0);
let last = null;
for (let i = 0; i < n; i++) last = bb.update(a[i], b[i]);
assert.ok(last.lower <= last.middle && last.middle <= last.upper);
});
test('SpreadBollingerBands batch is flat 4*n with last row matching', () => {
const n = 60;
const b = Array.from({ length: n }, (_, t) => 100 + t);
const a = b.map((v, t) => v + 3 * Math.sin(t * 0.4));
const out = new wickra.SpreadBollingerBands(20, 2.0).batch(a, b);
assert.equal(out.length, 4 * n);
const base = 4 * (n - 1);
assert.ok(out[base + 2] <= out[base] && out[base] <= out[base + 1]);
});
test('RelativeStrengthAB constant ratio is flat (object output)', () => {
const rs = new wickra.RelativeStrengthAB(5, 5);
let last = null;
@@ -1202,6 +1251,39 @@ test('derivatives reject bad input', () => {
assert.throws(() => new wickra.FundingBasis().update(100, 0));
});
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.
const change = [
[1.0, 0.5, 2.0, -1.0], // 3 up, 1 down -> net +2
[-1.0, -0.5, -2.0, 1.0], // 1 up, 3 down -> net -2
[0.0, 0.0, 1.0, -1.0], // 1 up, 1 down -> net 0
];
const volume = change.map((row) => row.map(() => 10.0));
const flags = change.map((row) => row.map(() => false));
const ad = new wickra.AdvanceDecline();
// Cumulative line: +2 -> 0 -> 0.
assert.equal(ad.update(change[0], volume[0], flags[0], flags[0]), 2.0);
assert.equal(ad.update(change[1], volume[1], flags[1], flags[1]), 0.0);
assert.equal(ad.update(change[2], volume[2], flags[2], flags[2]), 0.0);
// batch matches streaming.
const batch = new wickra.AdvanceDecline().batch(change, volume, flags, flags);
assert.deepEqual(Array.from(batch), [2.0, 0.0, 0.0]);
});
test('market breadth: AdvanceDecline rejects ragged universe', () => {
assert.throws(() =>
new wickra.AdvanceDecline().update(
[1.0, -1.0],
[10.0],
[false, false],
[false, false],
),
);
});
test('OI / flow / liquidation indicators reference values', () => {
// OI +10% while price flat -> divergence +0.1.
const div = new wickra.OIPriceDivergence(1);
+169
View File
@@ -33,6 +33,26 @@ export interface RelativeStrengthValue {
/** RSI of the ratio. */
ratioRsi: number
}
/** Kalman hedge-ratio result: dynamic hedge ratio, intercept, and spread. */
export interface KalmanHedgeRatioValue {
/** Current hedge ratio (filtered slope of `a` on `b`). */
hedgeRatio: number
/** Current intercept (filtered level offset). */
intercept: number
/** Forecast error `a - (intercept + hedgeRatio*b)` — the spread signal. */
spread: number
}
/** Spread Bollinger-bands result: middle, upper and lower bands plus `%b`. */
export interface SpreadBollingerBandsValue {
/** Middle band: the rolling mean of the spread. */
middle: number
/** Upper band. */
upper: number
/** Lower band. */
lower: number
/** `%b`: where the spread sits across the band (`0` lower, `1` upper). */
percentB: number
}
/** MACD triple: macd line, signal line, histogram. */
export interface MacdValue {
macd: number
@@ -786,6 +806,84 @@ export declare class SpearmanCorrelation {
isReady(): boolean
warmupPeriod(): number
}
export type RollingCorrelationNode = RollingCorrelation
export declare class RollingCorrelation {
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 RollingCovarianceNode = RollingCovariance
export declare class RollingCovariance {
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 OuHalfLifeNode = OuHalfLife
export declare class OuHalfLife {
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 SpreadHurstNode = SpreadHurst
export declare class SpreadHurst {
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 DistanceSsdNode = DistanceSsd
export declare class DistanceSsd {
constructor(period: number)
update(x: number, y: number): number | null
/**
* Batch over two equally-sized arrays. Returns a length-`n` array
* with `NaN` for warmup positions.
*/
batch(x: Array<number>, y: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BetaNeutralSpreadNode = BetaNeutralSpread
export declare class BetaNeutralSpread {
constructor(period: number)
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)`
@@ -845,6 +943,68 @@ export declare class RelativeStrengthAB {
isReady(): boolean
warmupPeriod(): number
}
export type VarianceRatioNode = VarianceRatio
/**
* LoMacKinlay variance ratio: two ctor params (`period`, `q`), one `(a, b)`
* pair per update, a single ratio out.
*/
export declare class VarianceRatio {
constructor(period: number, q: number)
update(a: number, b: number): number | null
/**
* Batch over two equally-sized arrays. Returns a length-`n` array with
* `NaN` for warmup positions.
*/
batch(a: Array<number>, b: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type GrangerCausalityNode = GrangerCausality
/**
* Granger causality F-statistic: two ctor params (`period`, `lag`), one
* `(a, b)` pair per update, a single F-statistic out.
*/
export declare class GrangerCausality {
constructor(period: number, lag: number)
update(a: number, b: number): number | null
/**
* Batch over two equally-sized arrays. Returns a length-`n` array with
* `NaN` for warmup positions.
*/
batch(a: Array<number>, b: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type KalmanHedgeRatioNode = KalmanHedgeRatio
export declare class KalmanHedgeRatio {
constructor(delta: number, observationVar: number)
update(a: number, b: number): KalmanHedgeRatioValue | null
/**
* Batch over two equally-sized arrays. Returns a flat array of length
* `3 * n`, interleaved per row as `[hedgeRatio0, intercept0, spread0, ...]`.
* Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
*/
batch(a: Array<number>, b: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SpreadBollingerBandsNode = SpreadBollingerBands
export declare class SpreadBollingerBands {
constructor(period: number, numStd: number)
update(a: number, b: number): SpreadBollingerBandsValue | null
/**
* Batch over two equally-sized arrays. Returns a flat array of length
* `4 * n`, interleaved per row as `[middle0, upper0, lower0, percentB0, ...]`.
* Read column `j` of row `i` as `result[i * 4 + j]`. Warmup rows are `NaN`.
*/
batch(a: Array<number>, b: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MacdNode = MACD
export declare class MACD {
constructor(fast: number, slow: number, signal: number)
@@ -3084,6 +3244,15 @@ export declare class CalendarSpread {
isReady(): boolean
warmupPeriod(): number
}
export type AdvanceDeclineNode = AdvanceDecline
export declare class AdvanceDecline {
constructor()
update(change: Array<number>, volume: Array<number>, newHigh: Array<boolean>, newLow: Array<boolean>): number | null
batch(change: Array<Array<number>>, volume: Array<Array<number>>, newHigh: Array<Array<boolean>>, newLow: Array<Array<boolean>>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SharpeRatioNode = SharpeRatio
export declare class SharpeRatio {
constructor(period: number, riskFree: number)
+12 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -362,10 +362,20 @@ module.exports.PearsonCorrelation = PearsonCorrelation
module.exports.Beta = Beta
module.exports.PairwiseBeta = PairwiseBeta
module.exports.SpearmanCorrelation = SpearmanCorrelation
module.exports.RollingCorrelation = RollingCorrelation
module.exports.RollingCovariance = RollingCovariance
module.exports.OuHalfLife = OuHalfLife
module.exports.SpreadHurst = SpreadHurst
module.exports.DistanceSsd = DistanceSsd
module.exports.BetaNeutralSpread = BetaNeutralSpread
module.exports.PairSpreadZScore = PairSpreadZScore
module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
module.exports.Cointegration = Cointegration
module.exports.RelativeStrengthAB = RelativeStrengthAB
module.exports.VarianceRatio = VarianceRatio
module.exports.GrangerCausality = GrangerCausality
module.exports.KalmanHedgeRatio = KalmanHedgeRatio
module.exports.SpreadBollingerBands = SpreadBollingerBands
module.exports.MACD = MACD
module.exports.MACDFIX = MACDFIX
module.exports.MACDEXT = MACDEXT
@@ -607,6 +617,7 @@ module.exports.TakerBuySellRatio = TakerBuySellRatio
module.exports.LiquidationFeatures = LiquidationFeatures
module.exports.TermStructureBasis = TermStructureBasis
module.exports.CalendarSpread = CalendarSpread
module.exports.AdvanceDecline = AdvanceDecline
module.exports.SharpeRatio = SharpeRatio
module.exports.SortinoRatio = SortinoRatio
module.exports.CalmarRatio = CalmarRatio
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.4.6",
"version": "0.4.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.4.6",
"version": "0.4.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.4.6",
"version": "0.4.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.4.6",
"version": "0.4.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.4.6",
"version": "0.4.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.4.6",
"version": "0.4.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.4.6",
"version": "0.4.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.4.6",
"version": "0.4.7",
"license": "PolyForm-Noncommercial-1.0.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.4.6",
"wickra-darwin-x64": "0.4.6",
"wickra-linux-arm64-gnu": "0.4.6",
"wickra-linux-x64-gnu": "0.4.6",
"wickra-win32-arm64-msvc": "0.4.6",
"wickra-win32-x64-msvc": "0.4.6"
"wickra-darwin-arm64": "0.4.7",
"wickra-darwin-x64": "0.4.7",
"wickra-linux-arm64-gnu": "0.4.7",
"wickra-linux-x64-gnu": "0.4.7",
"wickra-win32-arm64-msvc": "0.4.7",
"wickra-win32-x64-msvc": "0.4.7"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.6.tgz",
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.7.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.6.tgz",
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.7.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.6.tgz",
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.7.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.6.tgz",
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.7.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.6.tgz",
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.7.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.6.tgz",
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.7.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.4.6",
"version": "0.4.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.4.6",
"wickra-linux-arm64-gnu": "0.4.6",
"wickra-darwin-x64": "0.4.6",
"wickra-darwin-arm64": "0.4.6",
"wickra-win32-x64-msvc": "0.4.6",
"wickra-win32-arm64-msvc": "0.4.6"
"wickra-linux-x64-gnu": "0.4.7",
"wickra-linux-arm64-gnu": "0.4.7",
"wickra-darwin-x64": "0.4.7",
"wickra-darwin-arm64": "0.4.7",
"wickra-win32-x64-msvc": "0.4.7",
"wickra-win32-arm64-msvc": "0.4.7"
},
"scripts": {
"build": "napi build --platform --release",
+358
View File
@@ -322,6 +322,24 @@ node_pair_indicator!(
"SpearmanCorrelation",
wc::SpearmanCorrelation
);
node_pair_indicator!(
RollingCorrelationNode,
"RollingCorrelation",
wc::RollingCorrelation
);
node_pair_indicator!(
RollingCovarianceNode,
"RollingCovariance",
wc::RollingCovariance
);
node_pair_indicator!(OuHalfLifeNode, "OuHalfLife", wc::OuHalfLife);
node_pair_indicator!(SpreadHurstNode, "SpreadHurst", wc::SpreadHurst);
node_pair_indicator!(DistanceSsdNode, "DistanceSsd", wc::DistanceSsd);
node_pair_indicator!(
BetaNeutralSpreadNode,
"BetaNeutralSpread",
wc::BetaNeutralSpread
);
// ============================== PairSpreadZScore ==============================
@@ -581,6 +599,252 @@ impl RelativeStrengthAbNode {
}
}
// ============================== VarianceRatio ==============================
/// LoMacKinlay variance ratio: two ctor params (`period`, `q`), one `(a, b)`
/// pair per update, a single ratio out.
#[napi(js_name = "VarianceRatio")]
pub struct VarianceRatioNode {
inner: wc::VarianceRatio,
}
#[napi]
impl VarianceRatioNode {
#[napi(constructor)]
pub fn new(period: u32, q: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VarianceRatio::new(period as usize, q as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized arrays. Returns a length-`n` array with
/// `NaN` for warmup positions.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== GrangerCausality ==============================
/// Granger causality F-statistic: two ctor params (`period`, `lag`), one
/// `(a, b)` pair per update, a single F-statistic out.
#[napi(js_name = "GrangerCausality")]
pub struct GrangerCausalityNode {
inner: wc::GrangerCausality,
}
#[napi]
impl GrangerCausalityNode {
#[napi(constructor)]
pub fn new(period: u32, lag: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::GrangerCausality::new(period as usize, lag as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized arrays. Returns a length-`n` array with
/// `NaN` for warmup positions.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== KalmanHedgeRatio ==============================
/// Kalman hedge-ratio result: dynamic hedge ratio, intercept, and spread.
#[napi(object)]
pub struct KalmanHedgeRatioValue {
/// Current hedge ratio (filtered slope of `a` on `b`).
pub hedge_ratio: f64,
/// Current intercept (filtered level offset).
pub intercept: f64,
/// Forecast error `a - (intercept + hedgeRatio*b)` — the spread signal.
pub spread: f64,
}
#[napi(js_name = "KalmanHedgeRatio")]
pub struct KalmanHedgeRatioNode {
inner: wc::KalmanHedgeRatio,
}
#[napi]
impl KalmanHedgeRatioNode {
#[napi(constructor)]
pub fn new(delta: f64, observation_var: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::KalmanHedgeRatio::new(delta, observation_var).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<KalmanHedgeRatioValue> {
self.inner.update((a, b)).map(|o| KalmanHedgeRatioValue {
hedge_ratio: o.hedge_ratio,
intercept: o.intercept,
spread: o.spread,
})
}
/// Batch over two equally-sized arrays. Returns a flat array of length
/// `3 * n`, interleaved per row as `[hedgeRatio0, intercept0, spread0, ...]`.
/// Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = vec![f64::NAN; a.len() * 3];
for i in 0..a.len() {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.hedge_ratio;
out[i * 3 + 1] = o.intercept;
out[i * 3 + 2] = o.spread;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== SpreadBollingerBands ==============================
/// Spread Bollinger-bands result: middle, upper and lower bands plus `%b`.
#[napi(object)]
pub struct SpreadBollingerBandsValue {
/// Middle band: the rolling mean of the spread.
pub middle: f64,
/// Upper band.
pub upper: f64,
/// Lower band.
pub lower: f64,
/// `%b`: where the spread sits across the band (`0` lower, `1` upper).
pub percent_b: f64,
}
#[napi(js_name = "SpreadBollingerBands")]
pub struct SpreadBollingerBandsNode {
inner: wc::SpreadBollingerBands,
}
#[napi]
impl SpreadBollingerBandsNode {
#[napi(constructor)]
pub fn new(period: u32, num_std: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::SpreadBollingerBands::new(period as usize, num_std).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, a: f64, b: f64) -> Option<SpreadBollingerBandsValue> {
self.inner
.update((a, b))
.map(|o| SpreadBollingerBandsValue {
middle: o.middle,
upper: o.upper,
lower: o.lower,
percent_b: o.percent_b,
})
}
/// Batch over two equally-sized arrays. Returns a flat array of length
/// `4 * n`, interleaved per row as `[middle0, upper0, lower0, percentB0, ...]`.
/// Read column `j` of row `i` as `result[i * 4 + j]`. Warmup rows are `NaN`.
#[napi]
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
if a.len() != b.len() {
return Err(NapiError::new(
Status::InvalidArg,
"a and b must be equal length".to_string(),
));
}
let mut out = vec![f64::NAN; a.len() * 4];
for i in 0..a.len() {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 4] = o.middle;
out[i * 4 + 1] = o.upper;
out[i * 4 + 2] = o.lower;
out[i * 4 + 3] = o.percent_b;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== MACD ==============================
/// MACD triple: macd line, signal line, histogram.
@@ -11168,6 +11432,100 @@ impl CalendarSpreadNode {
}
}
// ---------- Market Breadth (CrossSection input) ----------
//
// A breadth tick is the per-symbol state of the whole universe, passed as four
// equal-length parallel arrays (`change`, `volume`, `newHigh`, `newLow`).
// `batch` takes one such group of arrays per tick.
fn build_cross_section(
change: &[f64],
volume: &[f64],
new_high: &[bool],
new_low: &[bool],
) -> napi::Result<wc::CrossSection> {
if change.len() != volume.len()
|| change.len() != new_high.len()
|| change.len() != new_low.len()
{
return Err(NapiError::from_reason(
"change, volume, newHigh and newLow must be equal length".to_string(),
));
}
let members = (0..change.len())
.map(|i| wc::Member::new(change[i], volume[i], new_high[i], new_low[i]))
.collect();
wc::CrossSection::new(members, 0).map_err(map_err)
}
#[napi(js_name = "AdvanceDecline")]
pub struct AdvanceDeclineNode {
inner: wc::AdvanceDecline,
}
impl Default for AdvanceDeclineNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl AdvanceDeclineNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::AdvanceDecline::new(),
}
}
#[napi]
pub fn update(
&mut self,
change: Vec<f64>,
volume: Vec<f64>,
new_high: Vec<bool>,
new_low: Vec<bool>,
) -> napi::Result<Option<f64>> {
Ok(self
.inner
.update(build_cross_section(&change, &volume, &new_high, &new_low)?))
}
#[napi]
pub fn batch(
&mut self,
change: Vec<Vec<f64>>,
volume: Vec<Vec<f64>>,
new_high: Vec<Vec<bool>>,
new_low: Vec<Vec<bool>>,
) -> napi::Result<Vec<f64>> {
if change.len() != volume.len()
|| change.len() != new_high.len()
|| change.len() != new_low.len()
{
return Err(NapiError::from_reason(
"change, volume, newHigh and newLow must have the same number of ticks".to_string(),
));
}
let mut out = Vec::with_capacity(change.len());
for i in 0..change.len() {
let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?;
out.push(self.inner.update(section).unwrap_or(f64::NAN));
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== Family 15: Risk / Performance ==============================
// Risk metrics with fallible `new` (most need `period >= 2`), so each wrapper
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.4.6"
version = "0.4.7"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = { text = "PolyForm-Noncommercial-1.0.0 with additional personal-account permissions; see LICENSE" }
+24
View File
@@ -159,6 +159,16 @@ from ._wickra import (
MarketFacilitationIndex,
EaseOfMovement,
# Statistics
SpreadBollingerBands,
KalmanHedgeRatio,
GrangerCausality,
VarianceRatio,
BetaNeutralSpread,
DistanceSsd,
SpreadHurst,
OuHalfLife,
RollingCovariance,
RollingCorrelation,
TypicalPrice,
MedianPrice,
WeightedClose,
@@ -341,6 +351,8 @@ from ._wickra import (
LiquidationFeatures,
TermStructureBasis,
CalendarSpread,
# Market Breadth
AdvanceDecline,
# Risk / Performance
SharpeRatio,
SortinoRatio,
@@ -497,6 +509,16 @@ __all__ = [
"MarketFacilitationIndex",
"EaseOfMovement",
# Statistics
"SpreadBollingerBands",
"KalmanHedgeRatio",
"GrangerCausality",
"VarianceRatio",
"BetaNeutralSpread",
"DistanceSsd",
"SpreadHurst",
"OuHalfLife",
"RollingCovariance",
"RollingCorrelation",
"TypicalPrice",
"MedianPrice",
"WeightedClose",
@@ -679,6 +701,8 @@ __all__ = [
"LiquidationFeatures",
"TermStructureBasis",
"CalendarSpread",
# Market Breadth
"AdvanceDecline",
# Risk / Performance
"SharpeRatio",
"SortinoRatio",
+790 -1
View File
@@ -29,7 +29,9 @@ fn map_err(e: wc::Error) -> PyErr {
| wc::Error::InvalidTick { .. }
| wc::Error::InvalidOrderBook { .. }
| wc::Error::InvalidTrade { .. }
| wc::Error::InvalidDerivatives { .. } => PyValueError::new_err(e.to_string()),
| wc::Error::InvalidDerivatives { .. }
| wc::Error::InvalidCrossSection { .. }
| wc::Error::InvalidParameter { .. } => PyValueError::new_err(e.to_string()),
}
}
@@ -12249,6 +12251,687 @@ impl PyRelativeStrengthAB {
}
}
// ============================== RollingCorrelation ==============================
#[pyclass(
name = "RollingCorrelation",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyRollingCorrelation {
inner: wc::RollingCorrelation,
}
#[pymethods]
impl PyRollingCorrelation {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::RollingCorrelation::new(period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("RollingCorrelation(period={})", self.inner.period())
}
}
// ============================== RollingCovariance ==============================
#[pyclass(
name = "RollingCovariance",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyRollingCovariance {
inner: wc::RollingCovariance,
}
#[pymethods]
impl PyRollingCovariance {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::RollingCovariance::new(period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("RollingCovariance(period={})", self.inner.period())
}
}
// ============================== OuHalfLife ==============================
#[pyclass(name = "OuHalfLife", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyOuHalfLife {
inner: wc::OuHalfLife,
}
#[pymethods]
impl PyOuHalfLife {
#[new]
#[pyo3(signature = (period=60))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::OuHalfLife::new(period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("OuHalfLife(period={})", self.inner.period())
}
}
// ============================== SpreadHurst ==============================
#[pyclass(name = "SpreadHurst", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySpreadHurst {
inner: wc::SpreadHurst,
}
#[pymethods]
impl PySpreadHurst {
#[new]
#[pyo3(signature = (period=60))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::SpreadHurst::new(period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("SpreadHurst(period={})", self.inner.period())
}
}
// ============================== DistanceSsd ==============================
#[pyclass(name = "DistanceSsd", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyDistanceSsd {
inner: wc::DistanceSsd,
}
#[pymethods]
impl PyDistanceSsd {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::DistanceSsd::new(period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("DistanceSsd(period={})", self.inner.period())
}
}
// ============================== BetaNeutralSpread ==============================
#[pyclass(
name = "BetaNeutralSpread",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyBetaNeutralSpread {
inner: wc::BetaNeutralSpread,
}
#[pymethods]
impl PyBetaNeutralSpread {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::BetaNeutralSpread::new(period).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("BetaNeutralSpread(period={})", self.inner.period())
}
}
// ============================== VarianceRatio ==============================
#[pyclass(name = "VarianceRatio", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyVarianceRatio {
inner: wc::VarianceRatio,
}
#[pymethods]
impl PyVarianceRatio {
#[new]
#[pyo3(signature = (period=60, q=2))]
fn new(period: usize, q: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VarianceRatio::new(period, q).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn q(&self) -> usize {
self.inner.q()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"VarianceRatio(period={}, q={})",
self.inner.period(),
self.inner.q()
)
}
}
// ============================== GrangerCausality ==============================
#[pyclass(
name = "GrangerCausality",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyGrangerCausality {
inner: wc::GrangerCausality,
}
#[pymethods]
impl PyGrangerCausality {
#[new]
#[pyo3(signature = (period=60, lag=1))]
fn new(period: usize, lag: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::GrangerCausality::new(period, lag).map_err(map_err)?,
})
}
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized numpy arrays: `a` and `b`.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let mut out = Vec::with_capacity(xs.len());
for i in 0..xs.len() {
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn lag(&self) -> usize {
self.inner.lag()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"GrangerCausality(period={}, lag={})",
self.inner.period(),
self.inner.lag()
)
}
}
// ============================== KalmanHedgeRatio ==============================
#[pyclass(
name = "KalmanHedgeRatio",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyKalmanHedgeRatio {
inner: wc::KalmanHedgeRatio,
}
#[pymethods]
impl PyKalmanHedgeRatio {
#[new]
#[pyo3(signature = (delta=1e-4, observation_var=1e-3))]
fn new(delta: f64, observation_var: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::KalmanHedgeRatio::new(delta, observation_var).map_err(map_err)?,
})
}
/// Returns `(hedge_ratio, intercept, spread)` or `None` during warmup.
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
self.inner
.update((a, b))
.map(|o| (o.hedge_ratio, o.intercept, o.spread))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 3)` with columns `[hedge_ratio, intercept, spread]`. Warmup rows are
/// NaN.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let n = xs.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 3] = o.hedge_ratio;
out[i * 3 + 1] = o.intercept;
out[i * 3 + 2] = o.spread;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn delta(&self) -> f64 {
self.inner.delta()
}
#[getter]
fn observation_var(&self) -> f64 {
self.inner.observation_var()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"KalmanHedgeRatio(delta={}, observation_var={})",
self.inner.delta(),
self.inner.observation_var()
)
}
}
// ============================== SpreadBollingerBands ==============================
#[pyclass(
name = "SpreadBollingerBands",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PySpreadBollingerBands {
inner: wc::SpreadBollingerBands,
}
#[pymethods]
impl PySpreadBollingerBands {
#[new]
#[pyo3(signature = (period=20, num_std=2.0))]
fn new(period: usize, num_std: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::SpreadBollingerBands::new(period, num_std).map_err(map_err)?,
})
}
/// Returns `(middle, upper, lower, percent_b)` or `None` during warmup.
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64, f64)> {
self.inner
.update((a, b))
.map(|o| (o.middle, o.upper, o.lower, o.percent_b))
}
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
/// `(n, 4)` with columns `[middle, upper, lower, percent_b]`. Warmup rows are
/// NaN.
fn batch<'py>(
&mut self,
py: Python<'py>,
a: PyReadonlyArray1<'py, f64>,
b: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let xs = a
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let ys = b
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if xs.len() != ys.len() {
return Err(PyValueError::new_err("a and b must be equal length"));
}
let n = xs.len();
let mut out = vec![f64::NAN; n * 4];
for i in 0..n {
if let Some(o) = self.inner.update((xs[i], ys[i])) {
out[i * 4] = o.middle;
out[i * 4 + 1] = o.upper;
out[i * 4 + 2] = o.lower;
out[i * 4 + 3] = o.percent_b;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 4), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn num_std(&self) -> f64 {
self.inner.num_std()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"SpreadBollingerBands(period={}, num_std={})",
self.inner.period(),
self.inner.num_std()
)
}
}
// ============================== SpearmanCorrelation ==============================
#[pyclass(
@@ -14383,6 +15066,101 @@ impl PyCalendarSpread {
}
}
// ============================== Market Breadth ==============================
//
// Market-breadth indicators consume a `CrossSection`: one tick carrying the
// per-symbol state of the whole universe. The Python convention passes a tick as
// four equal-length parallel arrays (`change`, `volume`, `new_high`, `new_low`);
// `batch` takes one such group of arrays per tick.
fn build_cross_section(
change: &[f64],
volume: &[f64],
new_high: &[bool],
new_low: &[bool],
) -> PyResult<wc::CrossSection> {
if change.len() != volume.len()
|| change.len() != new_high.len()
|| change.len() != new_low.len()
{
return Err(PyValueError::new_err(
"change, volume, new_high and new_low must be equal length",
));
}
let members = (0..change.len())
.map(|i| wc::Member::new(change[i], volume[i], new_high[i], new_low[i]))
.collect();
wc::CrossSection::new(members, 0).map_err(map_err)
}
// AdvanceDecline takes no parameters; streaming `update(change, volume, new_high,
// new_low)` over one universe, `batch` over one such array group per tick.
#[pyclass(
name = "AdvanceDecline",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyAdvanceDecline {
inner: wc::AdvanceDecline,
}
#[pymethods]
impl PyAdvanceDecline {
#[new]
fn new() -> Self {
Self {
inner: wc::AdvanceDecline::new(),
}
}
fn update(
&mut self,
change: Vec<f64>,
volume: Vec<f64>,
new_high: Vec<bool>,
new_low: Vec<bool>,
) -> PyResult<Option<f64>> {
Ok(self
.inner
.update(build_cross_section(&change, &volume, &new_high, &new_low)?))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
change: Vec<Vec<f64>>,
volume: Vec<Vec<f64>>,
new_high: Vec<Vec<bool>>,
new_low: Vec<Vec<bool>>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
if change.len() != volume.len()
|| change.len() != new_high.len()
|| change.len() != new_low.len()
{
return Err(PyValueError::new_err(
"change, volume, new_high and new_low must have the same number of ticks",
));
}
let mut out = Vec::with_capacity(change.len());
for i in 0..change.len() {
let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?;
out.push(self.inner.update(section).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
"AdvanceDecline()".to_string()
}
}
// ============================== Family 15: Risk / Performance ==============================
#[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)]
@@ -15676,6 +16454,16 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyLeadLagCrossCorrelation>()?;
m.add_class::<PyCointegration>()?;
m.add_class::<PyRelativeStrengthAB>()?;
m.add_class::<PyRollingCorrelation>()?;
m.add_class::<PyRollingCovariance>()?;
m.add_class::<PyOuHalfLife>()?;
m.add_class::<PySpreadHurst>()?;
m.add_class::<PyDistanceSsd>()?;
m.add_class::<PyBetaNeutralSpread>()?;
m.add_class::<PyVarianceRatio>()?;
m.add_class::<PyGrangerCausality>()?;
m.add_class::<PyKalmanHedgeRatio>()?;
m.add_class::<PySpreadBollingerBands>()?;
m.add_class::<PySpearmanCorrelation>()?;
m.add_class::<PyValueArea>()?;
m.add_class::<PyVolumeProfile>()?;
@@ -15776,6 +16564,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyLiquidationFeatures>()?;
m.add_class::<PyTermStructureBasis>()?;
m.add_class::<PyCalendarSpread>()?;
m.add_class::<PyAdvanceDecline>()?;
// Family 15: Risk / Performance metrics.
m.add_class::<PySharpeRatio>()?;
m.add_class::<PySortinoRatio>()?;
@@ -167,6 +167,14 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
# --- Two-series (asset, benchmark) indicators -----------------------------
PAIR = [
(ta.GrangerCausality, (60, 1)),
(ta.VarianceRatio, (60, 2)),
(ta.BetaNeutralSpread, (20,)),
(ta.DistanceSsd, (20,)),
(ta.SpreadHurst, (60,)),
(ta.OuHalfLife, (60,)),
(ta.RollingCovariance, (20,)),
(ta.RollingCorrelation, (20,)),
(ta.TreynorRatio, (20, 0.0)),
(ta.InformationRatio, (20,)),
(ta.Alpha, (20, 0.0)),
@@ -251,6 +259,42 @@ def test_cointegration_streaming_matches_batch():
assert math.isclose(batch[i, 2], adf, rel_tol=1e-12, abs_tol=1e-12)
def test_kalman_hedge_ratio_converges_and_streaming_matches_batch():
n = 500
b = np.array([100.0 + 95.0 * math.sin(t * 0.5) for t in range(n)])
a = 2.0 * b + 5.0 # a = 2*b + 5 with a wide-ranging b ⇒ identifiable
batch = ta.KalmanHedgeRatio(1e-2, 1e-3).batch(a, b)
assert batch.shape == (n, 3)
assert abs(batch[-1, 0] - 2.0) < 0.05 # hedge ratio
assert abs(batch[-1, 2]) < 0.05 # spread (forecast error)
streamer = ta.KalmanHedgeRatio(1e-2, 1e-3)
for i in range(n):
hr, ic, sp = streamer.update(float(a[i]), float(b[i]))
assert math.isclose(batch[i, 0], hr, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], ic, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], sp, rel_tol=1e-12, abs_tol=1e-12)
def test_spread_bollinger_bands_streaming_matches_batch():
n = 60
b = np.array([100.0 + t for t in range(n)])
a = b + 3.0 * np.sin(np.arange(n) * 0.4)
batch = ta.SpreadBollingerBands(20, 2.0).batch(a, b)
assert batch.shape == (n, 4)
streamer = ta.SpreadBollingerBands(20, 2.0)
for i in range(n):
v = streamer.update(float(a[i]), float(b[i]))
if v is None:
assert np.all(np.isnan(batch[i]))
else:
mid, up, lo, pct_b = v
assert math.isclose(batch[i, 0], mid, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 1], up, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 2], lo, rel_tol=1e-12, abs_tol=1e-12)
assert math.isclose(batch[i, 3], pct_b, rel_tol=1e-12, abs_tol=1e-12)
assert lo <= mid <= up
def test_relative_strength_constant_ratio():
n = 30
a = np.full(n, 200.0)
@@ -2262,6 +2306,54 @@ def test_concealing_baby_swallow_reference():
assert t.update((11.0, 13.0, 9.9, 10.0, 1.0, 2)) == pytest.approx(0.0)
assert t.update((14.0, 14.1, 8.9, 9.0, 1.0, 3)) == pytest.approx(1.0)
def test_rolling_correlation_reference():
t = ta.RollingCorrelation(20)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_rolling_covariance_reference():
t = ta.RollingCovariance(20)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_ou_half_life_reference():
t = ta.OuHalfLife(60)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_spread_hurst_reference():
t = ta.SpreadHurst(60)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_distance_ssd_reference():
t = ta.DistanceSsd(20)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_beta_neutral_spread_reference():
t = ta.BetaNeutralSpread(20)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_variance_ratio_reference():
t = ta.VarianceRatio(60, 2)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
def test_granger_causality_reference():
t = ta.GrangerCausality(60, 1)
assert t.update(1.0, 1.0) is None
assert t.update(2.0, 1.5) is None
# --- Lifecycle ------------------------------------------------------------
@@ -2659,6 +2751,38 @@ def test_funding_indicators_streaming_equals_batch():
assert _eq_nan(batch, streamed)
def test_advance_decline_streaming_equals_batch():
# Three ticks over a universe of four symbols; the sign of `change`
# classifies each symbol as advancing / declining / unchanged.
change = [
[1.0, 0.5, 2.0, -1.0], # 3 up, 1 down -> net +2
[-1.0, -0.5, -2.0, 1.0], # 1 up, 3 down -> net -2
[0.0, 0.0, 1.0, -1.0], # 1 up, 1 down -> net 0
]
volume = [[10.0] * 4 for _ in range(3)]
new_high = [[False] * 4 for _ in range(3)]
new_low = [[False] * 4 for _ in range(3)]
batch = ta.AdvanceDecline().batch(change, volume, new_high, new_low)
streamer = ta.AdvanceDecline()
streamed = np.array(
[
streamer.update(change[i], volume[i], new_high[i], new_low[i])
for i in range(3)
],
dtype=np.float64,
)
assert batch.shape == (3,)
assert _eq_nan(batch, streamed)
# Cumulative line: +2 -> 0 -> 0.
assert list(batch) == [2.0, 0.0, 0.0]
def test_advance_decline_rejects_ragged_universe():
ad = ta.AdvanceDecline()
with pytest.raises(ValueError):
ad.update([1.0, -1.0], [10.0], [False, False], [False, False])
def test_funding_basis_streaming_equals_batch():
n = 40
index = np.array([100.0 + 0.5 * math.sin(i * 0.2) for i in range(n)], dtype=np.float64)
+294
View File
@@ -531,6 +531,24 @@ wasm_pair_indicator!(
"SpearmanCorrelation",
wc::SpearmanCorrelation
);
wasm_pair_indicator!(
WasmRollingCorrelation,
"RollingCorrelation",
wc::RollingCorrelation
);
wasm_pair_indicator!(
WasmRollingCovariance,
"RollingCovariance",
wc::RollingCovariance
);
wasm_pair_indicator!(WasmOuHalfLife, "OuHalfLife", wc::OuHalfLife);
wasm_pair_indicator!(WasmSpreadHurst, "SpreadHurst", wc::SpreadHurst);
wasm_pair_indicator!(WasmDistanceSsd, "DistanceSsd", wc::DistanceSsd);
wasm_pair_indicator!(
WasmBetaNeutralSpread,
"BetaNeutralSpread",
wc::BetaNeutralSpread
);
// ---------- PairSpreadZScore (two params) ----------
@@ -748,6 +766,210 @@ impl WasmRelativeStrengthAb {
}
}
// ---------- VarianceRatio (two params) ----------
#[wasm_bindgen(js_name = "VarianceRatio")]
pub struct WasmVarianceRatio {
inner: wc::VarianceRatio,
}
#[wasm_bindgen(js_class = "VarianceRatio")]
impl WasmVarianceRatio {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, q: usize) -> Result<WasmVarianceRatio, JsError> {
Ok(Self {
inner: wc::VarianceRatio::new(period, q).map_err(map_err)?,
})
}
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized arrays of prices. Returns one `f64` per
/// input position (`NaN` during warmup).
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- GrangerCausality (two params) ----------
#[wasm_bindgen(js_name = "GrangerCausality")]
pub struct WasmGrangerCausality {
inner: wc::GrangerCausality,
}
#[wasm_bindgen(js_class = "GrangerCausality")]
impl WasmGrangerCausality {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, lag: usize) -> Result<WasmGrangerCausality, JsError> {
Ok(Self {
inner: wc::GrangerCausality::new(period, lag).map_err(map_err)?,
})
}
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
self.inner.update((a, b))
}
/// Batch over two equally-sized arrays of prices. Returns one `f64` per
/// input position (`NaN` during warmup).
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let mut out = Vec::with_capacity(a.len());
for i in 0..a.len() {
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- KalmanHedgeRatio (two params, object output) ----------
#[wasm_bindgen(js_name = "KalmanHedgeRatio")]
pub struct WasmKalmanHedgeRatio {
inner: wc::KalmanHedgeRatio,
}
#[wasm_bindgen(js_class = "KalmanHedgeRatio")]
impl WasmKalmanHedgeRatio {
#[wasm_bindgen(constructor)]
pub fn new(delta: f64, observation_var: f64) -> Result<WasmKalmanHedgeRatio, JsError> {
Ok(Self {
inner: wc::KalmanHedgeRatio::new(delta, observation_var).map_err(map_err)?,
})
}
/// Returns `{ hedgeRatio, intercept, spread }`, or `null` during warmup.
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
match self.inner.update((a, b)) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"hedgeRatio".into(), &o.hedge_ratio.into()).ok();
Reflect::set(&obj, &"intercept".into(), &o.intercept.into()).ok();
Reflect::set(&obj, &"spread".into(), &o.spread.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Flat `Float64Array` of length `3 * n`:
/// `[hedgeRatio0, intercept0, spread0, hedgeRatio1, ...]`. Warmup rows are NaN.
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let n = a.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 3] = o.hedge_ratio;
out[i * 3 + 1] = o.intercept;
out[i * 3 + 2] = o.spread;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- SpreadBollingerBands (two params, object output) ----------
#[wasm_bindgen(js_name = "SpreadBollingerBands")]
pub struct WasmSpreadBollingerBands {
inner: wc::SpreadBollingerBands,
}
#[wasm_bindgen(js_class = "SpreadBollingerBands")]
impl WasmSpreadBollingerBands {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, num_std: f64) -> Result<WasmSpreadBollingerBands, JsError> {
Ok(Self {
inner: wc::SpreadBollingerBands::new(period, num_std).map_err(map_err)?,
})
}
/// Returns `{ middle, upper, lower, percentB }`, or `null` during warmup.
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
match self.inner.update((a, b)) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
Reflect::set(&obj, &"percentB".into(), &o.percent_b.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Flat `Float64Array` of length `4 * n`:
/// `[middle0, upper0, lower0, percentB0, middle1, ...]`. Warmup rows are NaN.
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
if a.len() != b.len() {
return Err(JsError::new("a and b must be equal length"));
}
let n = a.len();
let mut out = vec![f64::NAN; n * 4];
for i in 0..n {
if let Some(o) = self.inner.update((a[i], b[i])) {
out[i * 4] = o.middle;
out[i * 4 + 1] = o.upper;
out[i * 4 + 2] = o.lower;
out[i * 4 + 3] = o.percent_b;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
// ---------- KAMA (three params) ----------
#[wasm_bindgen(js_name = KAMA)]
@@ -8143,6 +8365,78 @@ impl WasmCalendarSpread {
}
}
// ---------- Market Breadth (CrossSection input) ----------
//
// A breadth tick is the per-symbol state of the whole universe, passed as four
// equal-length parallel arrays (`change`, `volume`, `newHigh`, `newLow`). The
// high/low flag arrays are numeric (non-zero is true) so the whole tick crosses
// the wasm boundary as `Float64Array`s. The universe is ragged across ticks, so
// only `update` is exposed (no `batch`), matching the other multi-input wasm
// indicators.
fn build_cross_section(
change: &[f64],
volume: &[f64],
new_high: &[f64],
new_low: &[f64],
) -> Result<wc::CrossSection, JsError> {
if change.len() != volume.len()
|| change.len() != new_high.len()
|| change.len() != new_low.len()
{
return Err(JsError::new(
"change, volume, newHigh and newLow must be equal length",
));
}
let members = (0..change.len())
.map(|i| wc::Member::new(change[i], volume[i], new_high[i] != 0.0, new_low[i] != 0.0))
.collect();
wc::CrossSection::new(members, 0).map_err(map_err)
}
#[wasm_bindgen(js_name = AdvanceDecline)]
pub struct WasmAdvanceDecline {
inner: wc::AdvanceDecline,
}
impl Default for WasmAdvanceDecline {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = AdvanceDecline)]
impl WasmAdvanceDecline {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmAdvanceDecline {
Self {
inner: wc::AdvanceDecline::new(),
}
}
pub fn update(
&mut self,
change: Vec<f64>,
volume: Vec<f64>,
new_high: Vec<f64>,
new_low: Vec<f64>,
) -> Result<Option<f64>, JsError> {
Ok(self
.inner
.update(build_cross_section(&change, &volume, &new_high, &new_low)?))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[cfg(test)]
mod tests {
use super::*;
+226
View File
@@ -0,0 +1,226 @@
//! Cross-section value type: a market-breadth snapshot across a whole universe.
//!
//! A [`CrossSection`] is a single tick that carries the per-symbol state of
//! *every* symbol in a universe at one point in time. It is the non-OHLCV input
//! consumed by the market-breadth indicator family (advance/decline, `McClellan`,
//! the TRIN / Arms index, the high-low index, ...), each of which aggregates the
//! whole cross-section into a single breadth reading. This is the same
//! one-rich-type-per-family pattern as [`DerivativesTick`] and [`OrderBook`].
//!
//! Each [`Member`] precomputes the per-symbol signals the breadth indicators
//! need — a signed price `change` (whose sign classifies the symbol as
//! advancing, declining or unchanged), the period `volume`, and the
//! `new_high` / `new_low` extreme flags — so the indicators stay stateless per
//! tick and never have to track per-symbol history.
//!
//! [`DerivativesTick`]: crate::DerivativesTick
//! [`OrderBook`]: crate::OrderBook
use crate::error::{Error, Result};
/// One symbol's contribution to a [`CrossSection`] tick.
///
/// Field invariants enforced by [`CrossSection::new`] when the member is placed
/// into a tick:
///
/// - `change` is finite (its sign classifies the symbol — positive is
/// advancing, negative is declining, zero is unchanged).
/// - `volume` is finite and non-negative.
///
/// `new_high` / `new_low` are caller-supplied flags marking whether the symbol
/// printed a new period extreme; they carry no numeric invariant.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Member {
/// Price change versus the previous close. Sign classifies the symbol:
/// positive is advancing, negative is declining, zero is unchanged.
pub change: f64,
/// Period volume for the symbol (finite, non-negative).
pub volume: f64,
/// Whether the symbol printed a new period high.
pub new_high: bool,
/// Whether the symbol printed a new period low.
pub new_low: bool,
}
impl Member {
/// Assemble a cross-section member.
///
/// The field invariants documented on [`Member`] are validated centrally by
/// [`CrossSection::new`] when the member is placed into a tick; this
/// constructor only assembles the value so the `#[non_exhaustive]` struct can
/// be built from outside the crate.
#[must_use]
pub const fn new(change: f64, volume: f64, new_high: bool, new_low: bool) -> Self {
Self {
change,
volume,
new_high,
new_low,
}
}
}
/// A market-breadth cross-section: the per-symbol state of an entire universe at
/// a single point in time.
///
/// Invariants enforced by [`new`](CrossSection::new):
///
/// - `members` is non-empty (a breadth reading needs at least one symbol).
/// - every member's `change` is finite, and `volume` is finite and non-negative.
///
/// `timestamp` is a caller-defined epoch / resolution and is not validated.
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub struct CrossSection {
/// Per-symbol members of the universe for this tick.
pub members: Vec<Member>,
/// Tick timestamp (caller-defined epoch / resolution).
pub timestamp: i64,
}
impl CrossSection {
/// Construct a cross-section, validating every member invariant.
///
/// # Errors
///
/// Returns [`Error::InvalidCrossSection`] if `members` is empty, if any
/// member has a non-finite `change`, or if any member has a `volume` that is
/// not a finite non-negative number.
pub fn new(members: Vec<Member>, timestamp: i64) -> Result<Self> {
if members.is_empty() {
return Err(Error::InvalidCrossSection {
message: "cross-section must contain at least one member",
});
}
for member in &members {
if !member.change.is_finite() {
return Err(Error::InvalidCrossSection {
message: "member change must be finite",
});
}
if !member.volume.is_finite() || member.volume < 0.0 {
return Err(Error::InvalidCrossSection {
message: "member volume must be finite and non-negative",
});
}
}
Ok(Self { members, timestamp })
}
/// Construct a cross-section without validation. The caller asserts that
/// every invariant documented on [`CrossSection`] holds.
#[must_use]
pub const fn new_unchecked(members: Vec<Member>, timestamp: i64) -> Self {
Self { members, timestamp }
}
/// Number of advancing symbols (those with a strictly positive `change`).
#[must_use]
pub fn advancers(&self) -> usize {
self.members.iter().filter(|m| m.change > 0.0).count()
}
/// Number of declining symbols (those with a strictly negative `change`).
#[must_use]
pub fn decliners(&self) -> usize {
self.members.iter().filter(|m| m.change < 0.0).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn members() -> Vec<Member> {
vec![
Member::new(1.5, 100.0, true, false),
Member::new(-0.5, 50.0, false, true),
Member::new(0.0, 0.0, false, false),
]
}
#[test]
fn new_accepts_valid() {
let cs = CrossSection::new(members(), 42).unwrap();
assert_eq!(cs.members.len(), 3);
assert_eq!(cs.timestamp, 42);
assert_eq!(cs.members[0].change, 1.5);
assert_eq!(cs.members[0].volume, 100.0);
assert!(cs.members[0].new_high);
assert!(cs.members[1].new_low);
}
#[test]
fn member_new_assembles_fields() {
let m = Member::new(2.0, 10.0, true, false);
assert_eq!(m.change, 2.0);
assert_eq!(m.volume, 10.0);
assert!(m.new_high);
assert!(!m.new_low);
}
#[test]
fn new_rejects_empty() {
assert!(matches!(
CrossSection::new(Vec::new(), 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_rejects_non_finite_change() {
assert!(matches!(
CrossSection::new(vec![Member::new(f64::NAN, 10.0, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
assert!(matches!(
CrossSection::new(vec![Member::new(f64::INFINITY, 10.0, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_rejects_negative_volume() {
assert!(matches!(
CrossSection::new(vec![Member::new(1.0, -1.0, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_rejects_non_finite_volume() {
assert!(matches!(
CrossSection::new(vec![Member::new(1.0, f64::NAN, false, false)], 0),
Err(Error::InvalidCrossSection { .. })
));
}
#[test]
fn new_unchecked_skips_validation() {
let cs = CrossSection::new_unchecked(vec![Member::new(f64::NAN, -1.0, false, false)], 7);
assert_eq!(cs.members.len(), 1);
assert_eq!(cs.timestamp, 7);
}
#[test]
fn advancers_and_decliners_count_by_sign() {
let cs = CrossSection::new(members(), 0).unwrap();
assert_eq!(cs.advancers(), 1);
assert_eq!(cs.decliners(), 1);
}
#[test]
fn unchanged_members_count_as_neither() {
let cs = CrossSection::new(
vec![
Member::new(0.0, 1.0, false, false),
Member::new(0.0, 1.0, false, false),
],
0,
)
.unwrap();
assert_eq!(cs.advancers(), 0);
assert_eq!(cs.decliners(), 0);
}
}
+15
View File
@@ -52,6 +52,21 @@ pub enum Error {
/// own variant.
#[error("invalid derivatives tick: {message}")]
InvalidDerivatives { message: &'static str },
/// A market-breadth cross-section whose members do not satisfy the
/// cross-section invariants (an empty universe, a non-finite change, or a
/// negative / non-finite volume) was provided. A cross-section is a
/// breadth input distinct from candles, ticks, order books and trades, so
/// it surfaces as its own variant.
#[error("invalid cross-section: {message}")]
InvalidCrossSection { message: &'static str },
/// A real-valued configuration parameter was outside its admissible range
/// (e.g. a non-positive standard-deviation multiplier, or a Kalman filter
/// covariance that is not strictly positive). This is the floating-point
/// analogue of [`Error::InvalidPeriod`], which only covers integer windows.
#[error("invalid parameter: {message}")]
InvalidParameter { message: &'static str },
}
/// Convenience alias for `Result<T, wickra_core::Error>`.
@@ -0,0 +1,168 @@
//! Advance/Decline Line — cumulative net advancing-minus-declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Advance/Decline Line (A/D Line) — the running cumulative sum of net advancing
/// issues across a universe.
///
/// On each [`CrossSection`] tick the net breadth is `advancers - decliners`:
/// the number of symbols with a positive price change minus the number with a
/// negative change (unchanged symbols are ignored). The line accumulates this
/// net value over time, so a rising line means advancers have persistently
/// outnumbered decliners — broad participation — while a falling line warns that
/// a rally is being carried by fewer and fewer names (a breadth divergence when
/// the index itself is still rising).
///
/// `Input = CrossSection`, `Output = f64`. The line is defined from the very
/// first tick, so `warmup_period == 1` and the indicator is ready after one
/// update.
///
/// # Example
///
/// ```
/// use wickra_core::{AdvanceDecline, CrossSection, Indicator, Member};
///
/// let mut ad = AdvanceDecline::new();
/// // 3 advancers, 1 decliner -> net +2.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(0.5, 10.0, false, false),
/// Member::new(2.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(ad.update(tick), Some(2.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdvanceDecline {
line: f64,
has_emitted: bool,
}
impl AdvanceDecline {
/// Construct a new Advance/Decline Line indicator.
#[must_use]
pub const fn new() -> Self {
Self {
line: 0.0,
has_emitted: false,
}
}
}
impl Indicator for AdvanceDecline {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancers() as f64 - section.decliners() as f64;
self.line += net;
self.has_emitted = true;
Some(self.line)
}
fn reset(&mut self) {
self.line = 0.0;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AdvanceDecline"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
/// Build a cross-section with `up` advancers, `down` decliners and `flat`
/// unchanged symbols.
fn section(up: usize, down: usize, flat: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..up {
members.push(Member::new(1.0, 10.0, false, false));
}
for _ in 0..down {
members.push(Member::new(-1.0, 10.0, false, false));
}
for _ in 0..flat {
members.push(Member::new(0.0, 10.0, false, false));
}
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let ad = AdvanceDecline::new();
assert_eq!(ad.name(), "AdvanceDecline");
assert_eq!(ad.warmup_period(), 1);
assert!(!ad.is_ready());
}
#[test]
fn first_tick_emits_net_breadth() {
let mut ad = AdvanceDecline::new();
assert_eq!(ad.update(section(3, 1, 0)), Some(2.0));
assert!(ad.is_ready());
}
#[test]
fn line_accumulates_across_ticks() {
let mut ad = AdvanceDecline::new();
assert_eq!(ad.update(section(3, 1, 0)), Some(2.0)); // +2 -> 2
assert_eq!(ad.update(section(1, 4, 0)), Some(-1.0)); // -3 -> -1
assert_eq!(ad.update(section(2, 0, 0)), Some(1.0)); // +2 -> 1
}
#[test]
fn unchanged_symbols_are_ignored() {
let mut ad = AdvanceDecline::new();
// 2 up, 2 down, 5 unchanged -> net 0, line stays flat.
assert_eq!(ad.update(section(2, 2, 5)), Some(0.0));
assert_eq!(ad.update(section(2, 2, 5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut ad = AdvanceDecline::new();
ad.update(section(5, 0, 0));
assert!(ad.is_ready());
ad.reset();
assert!(!ad.is_ready());
// Line restarts from zero, not from the pre-reset value.
assert_eq!(ad.update(section(1, 0, 0)), Some(1.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
section(3, 1, 2),
section(1, 4, 0),
section(2, 2, 1),
section(5, 0, 3),
];
let mut a = AdvanceDecline::new();
let mut b = AdvanceDecline::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,247 @@
//! Beta-neutral spread: the rolling OLS regression residual of two series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// The beta-neutral spread between two assets — the residual of a rolling
/// ordinary-least-squares regression of `a` on `b`.
///
/// Each `update` takes one `(a, b)` price pair. Over the trailing window of
/// `period` pairs the indicator fits the hedge ratio `β` (and intercept `α`) by
/// OLS and reports the **current** residual:
///
/// ```text
/// β = cov(a, b) / var(b) α = ā β · b̄
/// spread = a_now (α + β · b_now)
/// ```
///
/// Subtracting `β · b` removes `a`'s exposure to `b`, so the spread is market-
/// (beta-)neutral: it is what is left after the common factor is hedged out.
/// Positive means `a` is rich relative to its hedge, negative means cheap — the
/// raw signal a pairs trade fades. Where [`crate::PairSpreadZScore`] standardises
/// this residual into a z-score and [`crate::Cointegration`] bundles it with an
/// ADF test, this indicator returns the residual itself, in price units.
///
/// If `b` is flat over the window (`var(b) = 0`) there is no defined slope; the
/// indicator falls back to `β = 0`, so the spread becomes `a_now ā`.
///
/// Each `update` is `O(1)`: four running sums (`Σa`, `Σb`, `Σb²`, `Σab`) are
/// maintained as the window slides.
///
/// # Example
///
/// ```
/// use wickra_core::{BetaNeutralSpread, Indicator};
///
/// let mut s = BetaNeutralSpread::new(20).unwrap();
/// let mut last = None;
/// for t in 0..40 {
/// let b = 100.0 + f64::from(t);
/// // a = 2·b + 5 exactly ⇒ the regression explains a fully ⇒ spread ≈ 0.
/// last = s.update((2.0 * b + 5.0, b));
/// }
/// assert!(last.unwrap().abs() < 1e-6);
/// ```
#[derive(Debug, Clone)]
pub struct BetaNeutralSpread {
period: usize,
window: VecDeque<(f64, f64)>,
sum_a: f64,
sum_b: f64,
sum_bb: f64,
sum_ab: f64,
}
impl BetaNeutralSpread {
/// Construct a new beta-neutral spread.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression slope
/// needs at least two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "beta-neutral spread needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_a: 0.0,
sum_b: 0.0,
sum_bb: 0.0,
sum_ab: 0.0,
})
}
/// Configured look-back window.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for BetaNeutralSpread {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if self.window.len() == self.period {
let (oa, ob) = self.window.pop_front().expect("non-empty");
self.sum_a -= oa;
self.sum_b -= ob;
self.sum_bb -= ob * ob;
self.sum_ab -= oa * ob;
}
self.window.push_back((a, b));
self.sum_a += a;
self.sum_b += b;
self.sum_bb += b * b;
self.sum_ab += a * b;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_a = self.sum_a / n;
let mean_b = self.sum_b / n;
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
let (beta, intercept) = if var_b == 0.0 {
(0.0, mean_a)
} else {
let cov = self.sum_ab / n - mean_a * mean_b;
let slope = cov / var_b;
(slope, mean_a - slope * mean_b)
};
Some(a - (intercept + beta * b))
}
fn reset(&mut self) {
self.window.clear();
self.sum_a = 0.0;
self.sum_b = 0.0;
self.sum_bb = 0.0;
self.sum_ab = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"BetaNeutralSpread"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(BetaNeutralSpread::new(1).is_err());
assert!(BetaNeutralSpread::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let s = BetaNeutralSpread::new(20).unwrap();
assert_eq!(s.period(), 20);
assert_eq!(s.warmup_period(), 20);
assert_eq!(s.name(), "BetaNeutralSpread");
assert!(!s.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut s = BetaNeutralSpread::new(3).unwrap();
assert_eq!(s.update((1.0, 1.0)), None);
assert_eq!(s.update((2.0, 2.0)), None);
assert!(s.update((3.0, 3.0)).is_some());
assert!(s.is_ready());
}
#[test]
fn perfect_linear_relationship_has_zero_spread() {
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| {
let b = 100.0 + f64::from(t);
(2.0 * b + 5.0, b)
})
.collect();
let last = BetaNeutralSpread::new(20)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-6);
}
#[test]
fn dislocation_produces_nonzero_spread() {
// a tracks 2·b, then the last bar jumps up ⇒ positive residual.
let mut pairs: Vec<(f64, f64)> = (0..19)
.map(|t| {
let b = 100.0 + f64::from(t);
(2.0 * b + 5.0, b)
})
.collect();
pairs.push((2.0 * 119.0 + 5.0 + 10.0, 119.0));
let last = BetaNeutralSpread::new(20)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last > 1.0, "spread {last}");
}
#[test]
fn flat_b_falls_back_to_demeaned_a() {
// b constant ⇒ β = 0 ⇒ spread = a mean(a). Last window of a = 0..9,
// mean = 4.5, last a = 9 ⇒ spread = 4.5.
let pairs: Vec<(f64, f64)> = (0..10).map(|t| (f64::from(t), 7.0)).collect();
let last = BetaNeutralSpread::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 4.5, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut s = BetaNeutralSpread::new(4).unwrap();
s.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 5.0), (4.0, 9.0), (5.0, 2.0)]);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(s.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|t| {
let b = 30.0 + 0.7 * f64::from(t);
(1.8 * b + 2.0 + (f64::from(t) * 0.4).sin(), b)
})
.collect();
let batch = BetaNeutralSpread::new(20).unwrap().batch(&pairs);
let mut s = BetaNeutralSpread::new(20).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| s.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,235 @@
//! Gatev distance (sum of squared deviations) between two normalised series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Sum of squared deviations between two price series, normalised to a common
/// start — the classic Gatev et al. pairs-selection distance.
///
/// Each `update` takes one `(a, b)` price pair. Over the trailing window of
/// `period` pairs each series is rebased to `1` at the window's first bar and
/// the squared gap between the two normalised paths is summed:
///
/// ```text
/// ãᵢ = aᵢ / a_first b̃ᵢ = bᵢ / b_first
/// SSD = Σ (ãᵢ b̃ᵢ)²
/// ```
///
/// Rebasing puts the two series on the same scale (both start at `1`), so the
/// distance measures how far their *relative* paths drift apart. A **small**
/// SSD means the two assets track each other tightly — the screen Gatev,
/// Goetzmann and Rouwenhorst use to pick tradeable pairs; a large SSD means
/// they have decoupled. The output is always `≥ 0`. If either series is `0` at
/// the start of the window the normalisation is undefined and the indicator
/// returns `0`.
///
/// Each `update` is `O(period)`, bounded by the fixed window.
///
/// # Example
///
/// ```
/// use wickra_core::{DistanceSsd, Indicator};
///
/// let mut d = DistanceSsd::new(20).unwrap();
/// let mut last = None;
/// for t in 0..40 {
/// let base = 100.0 + f64::from(t);
/// // Two near-identical paths ⇒ tiny distance.
/// last = d.update((base, base * 1.0001));
/// }
/// assert!(last.unwrap() < 1e-3);
/// ```
#[derive(Debug, Clone)]
pub struct DistanceSsd {
period: usize,
window: VecDeque<(f64, f64)>,
}
impl DistanceSsd {
/// Construct a new Gatev distance estimator.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a distance needs at
/// least two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "distance SSD needs period >= 2",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured look-back window.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for DistanceSsd {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let &(a_first, b_first) = self.window.front().expect("window is full");
if a_first == 0.0 || b_first == 0.0 {
// Cannot rebase a series that starts at zero.
return Some(0.0);
}
let ssd = self
.window
.iter()
.map(|&(a, b)| {
let gap = a / a_first - b / b_first;
gap * gap
})
.sum();
Some(ssd)
}
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 {
"DistanceSsd"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(DistanceSsd::new(1).is_err());
assert!(DistanceSsd::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let d = DistanceSsd::new(20).unwrap();
assert_eq!(d.period(), 20);
assert_eq!(d.warmup_period(), 20);
assert_eq!(d.name(), "DistanceSsd");
assert!(!d.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut d = DistanceSsd::new(3).unwrap();
assert_eq!(d.update((1.0, 1.0)), None);
assert_eq!(d.update((2.0, 2.0)), None);
assert!(d.update((3.0, 3.0)).is_some());
assert!(d.is_ready());
}
#[test]
fn identical_normalised_paths_have_zero_distance() {
// b = 2·a ⇒ both rebase to the same path ⇒ SSD = 0.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|t| {
let a = 100.0 + f64::from(t);
(a, 2.0 * a)
})
.collect();
let last = DistanceSsd::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn diverging_paths_have_positive_distance() {
let pairs: Vec<(f64, f64)> = (0..20)
.map(|t| (100.0 + f64::from(t), 100.0 + 3.0 * f64::from(t)))
.collect();
let last = DistanceSsd::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last > 0.0, "ssd {last}");
}
#[test]
fn hand_computed_value() {
// Window of three pairs, a_first = b_first = 1:
// (1,1) → 0; (2,4) → (24)² = 4; (3,9) → (39)² = 36 ⇒ SSD = 40.
let pairs = [(1.0, 1.0), (2.0, 4.0), (3.0, 9.0)];
let last = DistanceSsd::new(3)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 40.0, epsilon = 1e-12);
}
#[test]
fn zero_start_returns_zero() {
// First bar of the window has a = 0 ⇒ rebasing undefined ⇒ 0.
let pairs = [(0.0, 1.0), (2.0, 2.0), (3.0, 3.0)];
let last = DistanceSsd::new(3)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn reset_clears_state() {
let mut d = DistanceSsd::new(4).unwrap();
d.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 4.0), (4.0, 5.0), (5.0, 6.0)]);
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|t| {
let a = 100.0 + f64::from(t);
(a, 100.0 + 1.2 * f64::from(t) + (f64::from(t) * 0.5).sin())
})
.collect();
let batch = DistanceSsd::new(15).unwrap().batch(&pairs);
let mut d = DistanceSsd::new(15).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| d.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,335 @@
//! Granger causality F-statistic: does series `b` help predict series `a`?
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Granger causality of `b` on `a` over a rolling window, as an F-statistic.
///
/// Each `update` takes one `(a, b)` pair. Over the trailing window of `period`
/// observations the indicator fits two autoregressions of `a` and compares them
/// with an F-test:
///
/// ```text
/// restricted: aₜ = c + Σ φᵢ·aₜ₋ᵢ (a's own lags only)
/// unrestricted: aₜ = c + Σ φᵢ·aₜ₋ᵢ + Σ ψᵢ·bₜ₋ᵢ (+ b's lags)
/// F = ((RSSᵣ RSSᵤ) / lag) / (RSSᵤ / (n 2·lag 1))
/// ```
///
/// If adding `b`'s lags significantly reduces the residual sum of squares, `b`
/// **Granger-causes** `a`: past values of `b` carry information about the future
/// of `a` beyond what `a`'s own past holds. A **larger** F means stronger
/// predictive causality (leadlag structure a stat-arb model can trade); a
/// value near `0` means `b` adds nothing. Note Granger causality is purely
/// predictive — it is not structural cause and effect.
///
/// The statistic is `0` when a regression is degenerate — a collinear or flat
/// window makes the normal equations singular. The output is always `≥ 0`.
///
/// Each `update` is `O(period · lag² + lag³)`, bounded by the fixed parameters.
///
/// # Example
///
/// ```
/// use wickra_core::{GrangerCausality, Indicator};
///
/// let mut g = GrangerCausality::new(60, 1).unwrap();
/// let mut last = None;
/// for t in 0..120 {
/// let drive = (f64::from(t) * 0.3).sin();
/// // a echoes b's previous value plus noise ⇒ b Granger-causes a.
/// let b = drive;
/// let a = 0.5 * (f64::from(t.max(1) - 1) * 0.3).sin() + 0.1 * (f64::from(t) * 0.9).cos();
/// last = g.update((a, b));
/// }
/// assert!(last.unwrap() >= 0.0);
/// ```
#[derive(Debug, Clone)]
pub struct GrangerCausality {
period: usize,
lag: usize,
window: VecDeque<(f64, f64)>,
}
impl GrangerCausality {
/// Construct a new Granger causality test.
///
/// `period` is the look-back window; `lag` is the autoregressive order
/// (number of own/cross lags in each model).
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `lag < 1` or if `period < 3·lag + 2`
/// (the smallest window that leaves the unrestricted regression at least one
/// residual degree of freedom).
pub fn new(period: usize, lag: usize) -> Result<Self> {
if lag < 1 {
return Err(Error::InvalidPeriod {
message: "granger causality needs lag >= 1",
});
}
if period < 3 * lag + 2 {
return Err(Error::InvalidPeriod {
message: "granger causality needs period >= 3*lag + 2",
});
}
Ok(Self {
period,
lag,
window: VecDeque::with_capacity(period),
})
}
/// Configured look-back window.
pub const fn period(&self) -> usize {
self.period
}
/// Configured autoregressive order.
pub const fn lag(&self) -> usize {
self.lag
}
}
impl Indicator for GrangerCausality {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period {
return None;
}
let lag = self.lag;
let a: Vec<f64> = self.window.iter().map(|&(av, _)| av).collect();
let b: Vec<f64> = self.window.iter().map(|&(_, bv)| bv).collect();
let num_obs = self.period - lag;
let mut target = Vec::with_capacity(num_obs);
let mut restricted = Vec::with_capacity(num_obs);
let mut unrestricted = Vec::with_capacity(num_obs);
for k in 0..num_obs {
let now = lag + k;
target.push(a[now]);
let mut row_r = Vec::with_capacity(lag + 1);
row_r.push(1.0);
for back in 1..=lag {
row_r.push(a[now - back]);
}
let mut row_u = row_r.clone();
for back in 1..=lag {
row_u.push(b[now - back]);
}
restricted.push(row_r);
unrestricted.push(row_u);
}
let Some(rss_r) = ols_rss(&restricted, &target, lag + 1) else {
return Some(0.0);
};
let Some(rss_u) = ols_rss(&unrestricted, &target, 2 * lag + 1) else {
return Some(0.0);
};
let dof = (num_obs - (2 * lag + 1)) as f64;
let numerator = (rss_r - rss_u) / lag as f64;
let denominator = rss_u / dof;
Some((numerator / denominator).max(0.0))
}
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 {
"GrangerCausality"
}
}
/// Residual sum of squares of the OLS fit of `target` on the design `rows`
/// (each a length-`num_reg` regressor vector). Returns `None` if the normal
/// equations are singular.
fn ols_rss(rows: &[Vec<f64>], target: &[f64], num_reg: usize) -> Option<f64> {
let mut xtx = vec![vec![0.0; num_reg]; num_reg];
let mut xty = vec![0.0; num_reg];
for (row, &observed) in rows.iter().zip(target) {
for (ri, &left) in row.iter().enumerate() {
xty[ri] += left * observed;
for (ci, &right) in row.iter().enumerate() {
xtx[ri][ci] += left * right;
}
}
}
let theta = solve(xtx, xty)?;
let mut rss = 0.0;
for (row, &observed) in rows.iter().zip(target) {
let pred: f64 = row
.iter()
.zip(&theta)
.map(|(coeff, value)| coeff * value)
.sum();
let resid = observed - pred;
rss += resid * resid;
}
Some(rss)
}
/// Solve the linear system `mat·x = rhs` by Gaussian elimination, returning
/// `None` if the matrix is (numerically) singular. `mat` is row-major.
fn solve(mut mat: Vec<Vec<f64>>, mut rhs: Vec<f64>) -> Option<Vec<f64>> {
let dim = rhs.len();
for col in 0..dim {
let pivot = mat[col][col];
if pivot.abs() < 1e-12 {
return None;
}
let pivot_row = mat[col].clone();
for row in (col + 1)..dim {
let factor = mat[row][col] / pivot;
for (cell, &above) in mat[row].iter_mut().zip(&pivot_row).skip(col) {
*cell -= factor * above;
}
rhs[row] -= factor * rhs[col];
}
}
let mut sol = vec![0.0; dim];
for row in (0..dim).rev() {
let known: f64 = mat[row]
.iter()
.zip(&sol)
.skip(row + 1)
.map(|(coeff, value)| coeff * value)
.sum();
sol[row] = (rhs[row] - known) / mat[row][row];
}
Some(sol)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_bad_parameters() {
assert!(GrangerCausality::new(10, 0).is_err()); // lag must be >= 1
assert!(GrangerCausality::new(4, 1).is_err()); // period must be >= 3*lag + 2
assert!(GrangerCausality::new(5, 1).is_ok());
}
#[test]
fn accessors_and_metadata() {
let g = GrangerCausality::new(60, 2).unwrap();
assert_eq!(g.period(), 60);
assert_eq!(g.lag(), 2);
assert_eq!(g.warmup_period(), 60);
assert_eq!(g.name(), "GrangerCausality");
assert!(!g.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut g = GrangerCausality::new(5, 1).unwrap();
for t in 0..4 {
assert_eq!(g.update((f64::from(t), f64::from(t) * 0.5)), None);
}
assert!(g.update((4.0, 2.0)).is_some());
assert!(g.is_ready());
}
#[test]
fn b_leading_a_has_positive_statistic() {
// a[t] is driven by b[t-1] plus a little of its own past ⇒ b helps.
let mut prev_drive = 0.0;
let pairs: Vec<(f64, f64)> = (0..120)
.map(|t| {
let drive = (f64::from(t) * 0.3).sin() + 0.4 * (f64::from(t) * 0.11).cos();
let a = 0.8 * prev_drive + 0.05 * (f64::from(t) * 0.7).sin();
prev_drive = drive;
(a, drive)
})
.collect();
let last = GrangerCausality::new(60, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last > 1.0, "F {last}");
}
#[test]
fn constant_b_is_singular_and_returns_zero() {
// b is constant ⇒ its lag columns are collinear with the intercept ⇒
// the unrestricted normal equations are singular ⇒ 0.
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| (f64::from(t) + (f64::from(t) * 0.6).sin(), 3.0))
.collect();
let last = GrangerCausality::new(20, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn constant_a_restricted_singular_returns_zero() {
// a is constant ⇒ its own lag columns are collinear with the intercept
// ⇒ the restricted normal equations are singular ⇒ 0.
let pairs: Vec<(f64, f64)> = (0..40).map(|t| (5.0, (f64::from(t) * 0.4).sin())).collect();
let last = GrangerCausality::new(20, 1)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn reset_clears_state() {
let mut g = GrangerCausality::new(8, 1).unwrap();
for t in 0..12 {
g.update((
f64::from(t) + (f64::from(t) * 0.7).sin(),
(f64::from(t) * 0.3).cos(),
));
}
assert!(g.is_ready());
g.reset();
assert!(!g.is_ready());
assert_eq!(g.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|t| {
let b = (f64::from(t) * 0.4).sin();
(
0.6 * (f64::from(t.max(1) - 1) * 0.4).sin() + 0.1 * f64::from(t % 3),
b,
)
})
.collect();
let batch = GrangerCausality::new(30, 2).unwrap().batch(&pairs);
let mut g = GrangerCausality::new(30, 2).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| g.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,290 @@
//! Kalman-filter dynamic hedge ratio between two series.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Output of [`KalmanHedgeRatio`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KalmanHedgeRatioOutput {
/// Current hedge ratio `β` — the filtered slope of `a` on `b`.
pub hedge_ratio: f64,
/// Current intercept `α` — the filtered level offset.
pub intercept: f64,
/// Forecast error `a (α + β·b)`: how far the latest `a` sits from the
/// Kalman-predicted relationship. This is the tradeable spread signal.
pub spread: f64,
}
/// Dynamic hedge ratio between two series, estimated online with a Kalman filter.
///
/// Each `update` takes one `(a, b)` price pair and treats the linear relation
/// `aₜ = αₜ + βₜ·bₜ + noise` as a state-space model whose hidden state
/// `[βₜ, αₜ]` follows a random walk. The filter updates the state from every
/// observation, so the hedge ratio **adapts continuously** instead of being a
/// flat OLS slope over a fixed window:
///
/// ```text
/// state xₜ = [βₜ, αₜ], drifts as a random walk with covariance Vw·I
/// observe aₜ = [bₜ, 1]·xₜ + εₜ, Var(εₜ) = observation_var
/// Vw = delta / (1 delta)
/// ```
///
/// `delta` controls how fast the hedge ratio is allowed to move: a larger
/// `delta` tracks regime changes faster but is noisier; a smaller `delta` is
/// smoother but slower. `observation_var` is the measurement-noise variance.
/// The reported `spread` (the filter's forecast error) is the mean-reverting
/// signal a pairs trade fades — the Kalman analogue of the
/// [`crate::Cointegration`] residual, but with a hedge ratio that breathes.
///
/// The filter emits an estimate from the **first** update (warmup of one bar);
/// early estimates are diffuse and settle as observations accumulate. Each
/// `update` is `O(1)` over the fixed 2×2 covariance.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, KalmanHedgeRatio};
///
/// let mut k = KalmanHedgeRatio::new(1e-2, 1e-3).unwrap();
/// let mut last = None;
/// for t in 0..400 {
/// // `b` sweeps a wide range so the slope and intercept are identifiable.
/// let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0;
/// last = k.update((2.0 * b + 5.0, b)); // a = 2·b + 5
/// }
/// let out = last.unwrap();
/// assert!((out.hedge_ratio - 2.0).abs() < 0.05);
/// assert!(out.spread.abs() < 0.05);
/// ```
#[derive(Debug, Clone)]
pub struct KalmanHedgeRatio {
delta: f64,
transition_var: f64,
observation_var: f64,
beta: f64,
alpha: f64,
// State covariance, row-major 2×2: [[p00, p01], [p10, p11]].
cov: [[f64; 2]; 2],
count: usize,
}
impl KalmanHedgeRatio {
/// Construct a new Kalman hedge-ratio filter.
///
/// `delta` is the state-drift ratio in `(0, 1)`; `observation_var` is the
/// measurement-noise variance (`> 0`).
///
/// # Errors
/// Returns [`Error::InvalidParameter`] if `delta` is not in `(0, 1)` or if
/// `observation_var` is not strictly positive (both must also be finite).
pub fn new(delta: f64, observation_var: f64) -> Result<Self> {
if !delta.is_finite() || delta <= 0.0 || delta >= 1.0 {
return Err(Error::InvalidParameter {
message: "kalman hedge ratio needs delta in (0, 1)",
});
}
if !observation_var.is_finite() || observation_var <= 0.0 {
return Err(Error::InvalidParameter {
message: "kalman hedge ratio needs observation_var > 0",
});
}
Ok(Self {
delta,
transition_var: delta / (1.0 - delta),
observation_var,
beta: 0.0,
alpha: 0.0,
cov: [[0.0; 2]; 2],
count: 0,
})
}
/// Configured state-drift ratio `delta`.
pub const fn delta(&self) -> f64 {
self.delta
}
/// Configured measurement-noise variance.
pub const fn observation_var(&self) -> f64 {
self.observation_var
}
}
impl Indicator for KalmanHedgeRatio {
type Input = (f64, f64);
type Output = KalmanHedgeRatioOutput;
fn update(&mut self, input: (f64, f64)) -> Option<KalmanHedgeRatioOutput> {
let (a, b) = input;
// Predicted state covariance: add the transition noise to the diagonal
// (the very first observation starts from a zero prior).
let mut cov_pred = self.cov;
if self.count > 0 {
cov_pred[0][0] += self.transition_var;
cov_pred[1][1] += self.transition_var;
}
// Observation row is F = [b, 1].
let predicted = self.beta * b + self.alpha;
let innovation = a - predicted;
// F·cov_pred (a 1×2 row).
let fr0 = b * cov_pred[0][0] + cov_pred[1][0];
let fr1 = b * cov_pred[0][1] + cov_pred[1][1];
// Innovation variance S = F·cov_pred·Fᵀ + observation_var ≥ observation_var > 0.
let innovation_var = fr0 * b + fr1 + self.observation_var;
// Kalman gain = cov_pred·Fᵀ / S.
let rft0 = cov_pred[0][0] * b + cov_pred[0][1];
let rft1 = cov_pred[1][0] * b + cov_pred[1][1];
let gain0 = rft0 / innovation_var;
let gain1 = rft1 / innovation_var;
// State update.
self.beta += gain0 * innovation;
self.alpha += gain1 * innovation;
// Covariance update P = cov_pred gain·(F·cov_pred).
self.cov[0][0] = cov_pred[0][0] - gain0 * fr0;
self.cov[0][1] = cov_pred[0][1] - gain0 * fr1;
self.cov[1][0] = cov_pred[1][0] - gain1 * fr0;
self.cov[1][1] = cov_pred[1][1] - gain1 * fr1;
self.count += 1;
Some(KalmanHedgeRatioOutput {
hedge_ratio: self.beta,
intercept: self.alpha,
spread: innovation,
})
}
fn reset(&mut self) {
self.beta = 0.0;
self.alpha = 0.0;
self.cov = [[0.0; 2]; 2];
self.count = 0;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.count >= 1
}
fn name(&self) -> &'static str {
"KalmanHedgeRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_bad_parameters() {
assert!(KalmanHedgeRatio::new(0.0, 1.0).is_err());
assert!(KalmanHedgeRatio::new(1.0, 1.0).is_err());
assert!(KalmanHedgeRatio::new(-0.1, 1.0).is_err());
assert!(KalmanHedgeRatio::new(f64::NAN, 1.0).is_err());
assert!(KalmanHedgeRatio::new(0.001, 0.0).is_err());
assert!(KalmanHedgeRatio::new(0.001, -1.0).is_err());
assert!(KalmanHedgeRatio::new(0.001, f64::INFINITY).is_err());
assert!(KalmanHedgeRatio::new(0.001, 0.001).is_ok());
}
#[test]
fn accessors_and_metadata() {
let k = KalmanHedgeRatio::new(0.001, 0.01).unwrap();
assert_eq!(k.delta(), 0.001);
assert_eq!(k.observation_var(), 0.01);
assert_eq!(k.warmup_period(), 1);
assert_eq!(k.name(), "KalmanHedgeRatio");
assert!(!k.is_ready());
}
#[test]
fn emits_from_first_update() {
let mut k = KalmanHedgeRatio::new(0.001, 0.001).unwrap();
let first = k.update((10.0, 5.0)).unwrap();
// The diffuse prior leaves the first state at the origin.
assert_eq!(first.hedge_ratio, 0.0);
assert_eq!(first.intercept, 0.0);
assert_eq!(first.spread, 10.0);
assert!(k.is_ready());
}
#[test]
fn converges_to_static_relationship() {
// a = 2·b + 5 ⇒ the filter should recover β ≈ 2, α ≈ 5, spread ≈ 0.
// `b` sweeps a wide range so β and α are jointly identifiable.
let pairs: Vec<(f64, f64)> = (0..500)
.map(|t| {
let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0;
(2.0 * b + 5.0, b)
})
.collect();
let out = KalmanHedgeRatio::new(1e-2, 1e-3)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(
(out.hedge_ratio - 2.0).abs() < 0.05,
"beta {}",
out.hedge_ratio
);
assert!((out.intercept - 5.0).abs() < 1.0, "alpha {}", out.intercept);
assert!(out.spread.abs() < 0.05, "spread {}", out.spread);
}
#[test]
fn tracks_a_changing_hedge_ratio() {
// Hedge ratio steps from 2 to 3 partway through; the filter should move
// toward the new ratio.
let mut pairs: Vec<(f64, f64)> = (0..300)
.map(|t| {
let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0;
(2.0 * b + 5.0, b)
})
.collect();
pairs.extend((0..300).map(|t| {
let b = 100.0 + (f64::from(t) * 0.5).cos() * 95.0;
(3.0 * b + 5.0, b)
}));
let out = KalmanHedgeRatio::new(1e-2, 1e-3)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(out.hedge_ratio > 2.5, "beta {}", out.hedge_ratio);
}
#[test]
fn reset_clears_state() {
let mut k = KalmanHedgeRatio::new(0.001, 0.001).unwrap();
for t in 0..50 {
let b = 100.0 + f64::from(t);
k.update((2.0 * b, b));
}
assert!(k.is_ready());
k.reset();
assert!(!k.is_ready());
let first = k.update((10.0, 5.0)).unwrap();
assert_eq!(first.hedge_ratio, 0.0);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..120)
.map(|t| {
let b = 30.0 + 0.7 * f64::from(t);
(1.8 * b + 2.0 + (f64::from(t) * 0.4).sin(), b)
})
.collect();
let batch = KalmanHedgeRatio::new(1e-3, 1e-2).unwrap().batch(&pairs);
let mut k = KalmanHedgeRatio::new(1e-3, 1e-2).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| k.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+39 -1
View File
@@ -11,6 +11,7 @@ mod ad_oscillator;
mod adaptive_cycle;
mod adl;
mod advance_block;
mod advance_decline;
mod adx;
mod adxr;
mod alligator;
@@ -32,6 +33,7 @@ mod awesome_oscillator_histogram;
mod balance_of_power;
mod belt_hold;
mod beta;
mod beta_neutral_spread;
mod bollinger;
mod bollinger_bandwidth;
mod breakaway;
@@ -66,6 +68,7 @@ mod demand_index;
mod demark_pivots;
mod depth_slope;
mod detrended_std_dev;
mod distance_ssd;
mod doji;
mod doji_star;
mod donchian;
@@ -100,6 +103,7 @@ mod funding_rate_zscore;
mod gain_loss_ratio;
mod gap_side_by_side_white;
mod garman_klass;
mod granger_causality;
mod gravestone_doji;
mod hammer;
mod hanging_man;
@@ -129,6 +133,7 @@ mod inverse_fisher_transform;
mod inverted_hammer;
mod jma;
mod kagi_bars;
mod kalman_hedge_ratio;
mod kama;
mod kelly_criterion;
mod keltner;
@@ -186,6 +191,7 @@ mod omega_ratio;
mod on_neck;
mod opening_marubozu;
mod opening_range;
mod ou_half_life;
mod pain_index;
mod pair_spread_zscore;
mod pairwise_beta;
@@ -217,6 +223,8 @@ mod rocp;
mod rocr;
mod rocr100;
mod rogers_satchell;
mod rolling_correlation;
mod rolling_covariance;
mod roofing_filter;
mod rsi;
mod rvi;
@@ -236,6 +244,8 @@ mod smma;
mod sortino_ratio;
mod spearman_correlation;
mod spinning_top;
mod spread_bollinger_bands;
mod spread_hurst;
mod stalled_pattern;
mod standard_error;
mod standard_error_bands;
@@ -294,6 +304,7 @@ mod upside_gap_two_crows;
mod value_area;
mod value_at_risk;
mod variance;
mod variance_ratio;
mod vertical_horizontal_filter;
mod vidya;
mod volty_stop;
@@ -325,6 +336,7 @@ pub use ad_oscillator::AdOscillator;
pub use adaptive_cycle::AdaptiveCycle;
pub use adl::Adl;
pub use advance_block::AdvanceBlock;
pub use advance_decline::AdvanceDecline;
pub use adx::{Adx, AdxOutput};
pub use adxr::Adxr;
pub use alligator::{Alligator, AlligatorOutput};
@@ -346,6 +358,7 @@ pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
pub use balance_of_power::BalanceOfPower;
pub use belt_hold::BeltHold;
pub use beta::Beta;
pub use beta_neutral_spread::BetaNeutralSpread;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use breakaway::Breakaway;
@@ -380,6 +393,7 @@ pub use demand_index::DemandIndex;
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
pub use depth_slope::DepthSlope;
pub use detrended_std_dev::DetrendedStdDev;
pub use distance_ssd::DistanceSsd;
pub use doji::Doji;
pub use doji_star::DojiStar;
pub use donchian::{Donchian, DonchianOutput};
@@ -414,6 +428,7 @@ pub use funding_rate_zscore::FundingRateZScore;
pub use gain_loss_ratio::GainLossRatio;
pub use gap_side_by_side_white::GapSideBySideWhite;
pub use garman_klass::GarmanKlassVolatility;
pub use granger_causality::GrangerCausality;
pub use gravestone_doji::GravestoneDoji;
pub use hammer::Hammer;
pub use hanging_man::HangingMan;
@@ -443,6 +458,7 @@ pub use inverse_fisher_transform::InverseFisherTransform;
pub use inverted_hammer::InvertedHammer;
pub use jma::Jma;
pub use kagi_bars::{KagiBar, KagiBars};
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
pub use kama::Kama;
pub use kelly_criterion::KellyCriterion;
pub use keltner::{Keltner, KeltnerOutput};
@@ -500,6 +516,7 @@ pub use omega_ratio::OmegaRatio;
pub use on_neck::OnNeck;
pub use opening_marubozu::OpeningMarubozu;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use ou_half_life::OuHalfLife;
pub use pain_index::PainIndex;
pub use pair_spread_zscore::PairSpreadZScore;
pub use pairwise_beta::PairwiseBeta;
@@ -531,6 +548,8 @@ pub use rocp::Rocp;
pub use rocr::Rocr;
pub use rocr100::Rocr100;
pub use rogers_satchell::RogersSatchellVolatility;
pub use rolling_correlation::RollingCorrelation;
pub use rolling_covariance::RollingCovariance;
pub use roofing_filter::RoofingFilter;
pub use rsi::Rsi;
pub use rvi::Rvi;
@@ -550,6 +569,8 @@ pub use smma::Smma;
pub use sortino_ratio::SortinoRatio;
pub use spearman_correlation::SpearmanCorrelation;
pub use spinning_top::SpinningTop;
pub use spread_bollinger_bands::{SpreadBollingerBands, SpreadBollingerBandsOutput};
pub use spread_hurst::SpreadHurst;
pub use stalled_pattern::StalledPattern;
pub use standard_error::StandardError;
pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput};
@@ -608,6 +629,7 @@ pub use upside_gap_two_crows::UpsideGapTwoCrows;
pub use value_area::{ValueArea, ValueAreaOutput};
pub use value_at_risk::ValueAtRisk;
pub use variance::Variance;
pub use variance_ratio::VarianceRatio;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vidya::Vidya;
pub use volty_stop::VoltyStop;
@@ -836,11 +858,26 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"PearsonCorrelation",
"Beta",
"SpearmanCorrelation",
"Cointegration",
"LeadLagCrossCorrelation",
"PairSpreadZScore",
"PairwiseBeta",
"RelativeStrengthAB",
"MidPrice",
"MidPoint",
"AvgPrice",
"LinRegIntercept",
"Tsf",
"RollingCorrelation",
"RollingCovariance",
"OuHalfLife",
"SpreadHurst",
"DistanceSsd",
"BetaNeutralSpread",
"VarianceRatio",
"GrangerCausality",
"KalmanHedgeRatio",
"SpreadBollingerBands",
],
),
(
@@ -1033,6 +1070,7 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Alt-Chart Bars",
&["RenkoBars", "KagiBars", "PointAndFigureBars"],
),
("Market Breadth", &["AdvanceDecline"]),
];
#[cfg(test)]
@@ -1061,6 +1099,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, 309, "FAMILIES total drifted from indicator count");
assert_eq!(total, 325, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,245 @@
//! OrnsteinUhlenbeck half-life of mean reversion for the spread of two series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Half-life of mean reversion of the spread `a b`, from an OrnsteinUhlenbeck
/// fit.
///
/// Each `update` takes one `(a, b)` price pair and forms the spread
/// `sₜ = aₜ bₜ`. Over the trailing window of `period` spreads the indicator
/// fits the discrete OrnsteinUhlenbeck (mean-reverting AR(1)) model by
/// ordinary least squares of the change on the level:
///
/// ```text
/// Δsₜ = λ · sₜ₋₁ + c + εₜ
/// half_life = ln(2) / λ (only when λ < 0)
/// ```
///
/// `λ` is the speed of mean reversion: a more negative `λ` pulls the spread back
/// to its mean faster. The **half-life** is the number of bars for a deviation
/// to decay by half — the single most useful number for sizing a pairs trade's
/// holding period and look-back. When the spread is not mean-reverting
/// (`λ ≥ 0`, a random walk or a trend) or the regression is degenerate (a flat
/// spread), the indicator returns `0`, meaning "no finite half-life".
///
/// Each `update` is `O(period)`: the OLS slope is recomputed from the window's
/// running geometry. Output is in bars and is always `≥ 0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, OuHalfLife};
///
/// let mut hl = OuHalfLife::new(40).unwrap();
/// let mut last = None;
/// for t in 0..120 {
/// let b = 100.0 + f64::from(t);
/// // `a` hugs `b` with a fast mean-reverting wobble ⇒ short half-life.
/// let a = b + 2.0 * (f64::from(t) * 0.9).sin();
/// last = hl.update((a, b));
/// }
/// let half_life = last.unwrap();
/// assert!(half_life > 0.0 && half_life < 40.0);
/// ```
#[derive(Debug, Clone)]
pub struct OuHalfLife {
period: usize,
window: VecDeque<f64>,
}
impl OuHalfLife {
/// Construct a new OrnsteinUhlenbeck half-life estimator.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 3` — the AR(1) regression
/// needs at least two observations (a slope and an intercept).
pub fn new(period: usize) -> Result<Self> {
if period < 3 {
return Err(Error::InvalidPeriod {
message: "OU half-life needs period >= 3",
});
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured look-back window of spreads.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for OuHalfLife {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(a - b);
if self.window.len() < self.period {
return None;
}
// OLS slope λ of Δsₜ on sₜ₋₁ over the window.
let spreads: Vec<f64> = self.window.iter().copied().collect();
let count = (spreads.len() - 1) as f64;
let mut sum_level = 0.0;
let mut sum_delta = 0.0;
let mut sum_ll = 0.0;
let mut sum_ld = 0.0;
for pair in spreads.windows(2) {
let level = pair[0];
let delta = pair[1] - pair[0];
sum_level += level;
sum_delta += delta;
sum_ll += level * level;
sum_ld += level * delta;
}
let mean_level = sum_level / count;
let mean_delta = sum_delta / count;
let var_level = sum_ll / count - mean_level * mean_level;
if var_level <= 0.0 {
// Flat spread: the regression has no defined slope.
return Some(0.0);
}
let cov = sum_ld / count - mean_level * mean_delta;
let lambda = cov / var_level;
if lambda >= 0.0 {
// Not mean-reverting (random walk or diverging): no finite half-life.
return Some(0.0);
}
Some(-std::f64::consts::LN_2 / lambda)
}
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 {
"OuHalfLife"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_period_below_three() {
assert!(OuHalfLife::new(2).is_err());
assert!(OuHalfLife::new(3).is_ok());
}
#[test]
fn accessors_and_metadata() {
let hl = OuHalfLife::new(30).unwrap();
assert_eq!(hl.period(), 30);
assert_eq!(hl.warmup_period(), 30);
assert_eq!(hl.name(), "OuHalfLife");
assert!(!hl.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut hl = OuHalfLife::new(4).unwrap();
assert_eq!(hl.update((1.0, 0.0)), None);
assert_eq!(hl.update((2.0, 0.0)), None);
assert_eq!(hl.update((3.0, 0.0)), None);
assert!(hl.update((4.0, 0.0)).is_some());
assert!(hl.is_ready());
}
#[test]
fn mean_reverting_spread_has_positive_half_life() {
// Fast sinusoidal spread around zero ⇒ strong mean reversion.
let pairs: Vec<(f64, f64)> = (0..120)
.map(|t| {
let b = 100.0 + f64::from(t);
let a = b + 2.0 * (f64::from(t) * 0.9).sin();
(a, b)
})
.collect();
let last = OuHalfLife::new(40)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last > 0.0 && last < 40.0, "half-life {last}");
}
#[test]
fn trending_spread_has_zero_half_life() {
// Spread = a b grows monotonically (λ ≥ 0) ⇒ no finite half-life.
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| (2.0 * f64::from(t), f64::from(t)))
.collect();
let last = OuHalfLife::new(20)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn flat_spread_returns_zero() {
// a b is constant ⇒ var(level) = 0 ⇒ undefined ⇒ 0.
let pairs: Vec<(f64, f64)> = (0..30)
.map(|t| (5.0 + f64::from(t), f64::from(t)))
.collect();
let last = OuHalfLife::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn reset_clears_state() {
let mut hl = OuHalfLife::new(5).unwrap();
for t in 0..10 {
hl.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
}
assert!(hl.is_ready());
hl.reset();
assert!(!hl.is_ready());
assert_eq!(hl.update((1.0, 0.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|t| {
let b = 50.0 + 0.5 * f64::from(t);
(b + (f64::from(t) * 0.6).sin(), b)
})
.collect();
let batch = OuHalfLife::new(25).unwrap().batch(&pairs);
let mut hl = OuHalfLife::new(25).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| hl.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,275 @@
//! Rolling Pearson correlation of the period-over-period *returns* of two series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling correlation of the **returns** of two synchronised series.
///
/// Where [`crate::PearsonCorrelation`] correlates the raw *levels* `(x, y)`,
/// this indicator first differences each channel into a one-step return and
/// correlates those returns over the trailing window:
///
/// ```text
/// rxₜ = xₜ xₜ₋₁ ryₜ = yₜ − yₜ₋₁
/// corr = cov(rx, ry) / √(var(rx) · var(ry))
/// ```
///
/// Return correlation is the quantity that matters for hedging and portfolio
/// risk: two assets can trend together (high level correlation) while their
/// day-to-day moves are nearly independent (low return correlation). The output
/// is in `[1, +1]`; a flat return channel makes the ratio undefined and the
/// indicator reports `0` rather than `NaN`. The value is clamped to `[1, +1]`
/// to absorb tiny floating-point overshoots near the boundaries.
///
/// Each `update` is O(1): the five running sums (`Σrx`, `Σry`, `Σrx²`, `Σry²`,
/// `Σrxry`) are maintained as the window of returns slides. The first level in
/// each channel produces no return, so a `period`-pair correlation needs
/// `period + 1` updates of warmup.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RollingCorrelation};
///
/// let mut rc = RollingCorrelation::new(10).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// // A varying path where y always moves with x ⇒ return correlation +1.
/// let x = (f64::from(i) * 0.5).sin() * 10.0;
/// last = rc.update((x, 2.0 * x));
/// }
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct RollingCorrelation {
period: usize,
prev: Option<(f64, f64)>,
window: VecDeque<(f64, f64)>,
sum_x: f64,
sum_y: f64,
sum_xx: f64,
sum_yy: f64,
sum_xy: f64,
}
impl RollingCorrelation {
/// Construct a new rolling return-correlation.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — correlation is
/// undefined for fewer than two return pairs.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "rolling correlation 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,
sum_xy: 0.0,
})
}
/// Configured window of returns.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RollingCorrelation {
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 {
// First level in each channel: store it, no return yet.
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.sum_xy -= ox * 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;
self.sum_xy += rx * ry;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_x = self.sum_x / n;
let mean_y = self.sum_y / n;
let var_x = (self.sum_xx / n - mean_x * mean_x).max(0.0);
let var_y = (self.sum_yy / n - mean_y * mean_y).max(0.0);
let cov = self.sum_xy / n - mean_x * mean_y;
let denom = (var_x * var_y).sqrt();
if denom == 0.0 {
// At least one return channel is flat: correlation is undefined.
return Some(0.0);
}
Some((cov / denom).clamp(-1.0, 1.0))
}
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;
self.sum_xy = 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 {
"RollingCorrelation"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(RollingCorrelation::new(0).is_err());
assert!(RollingCorrelation::new(1).is_err());
assert!(RollingCorrelation::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let rc = RollingCorrelation::new(14).unwrap();
assert_eq!(rc.period(), 14);
assert_eq!(rc.warmup_period(), 15);
assert_eq!(rc.name(), "RollingCorrelation");
assert!(!rc.is_ready());
}
#[test]
fn warmup_needs_period_plus_one() {
let mut rc = RollingCorrelation::new(3).unwrap();
// First update only seeds the previous level ⇒ None.
assert_eq!(rc.update((1.0, 1.0)), None);
assert_eq!(rc.update((2.0, 3.0)), None); // 1 return
assert_eq!(rc.update((3.0, 5.0)), None); // 2 returns
assert!(rc.update((4.0, 7.0)).is_some()); // 3 returns ⇒ ready
assert!(rc.is_ready());
}
#[test]
fn comoving_returns_are_plus_one() {
// y always moves by 2x x's move ⇒ perfectly correlated returns.
let pairs: Vec<(f64, f64)> = (0..20)
.map(|i| {
let x = (f64::from(i) * 0.5).sin() * 10.0;
(x, 2.0 * x + 100.0)
})
.collect();
let last = RollingCorrelation::new(8)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
}
#[test]
fn opposing_returns_are_minus_one() {
let pairs: Vec<(f64, f64)> = (0..20)
.map(|i| {
let x = (f64::from(i) * 0.5).sin() * 10.0;
(x, -1.5 * x + 50.0)
})
.collect();
let last = RollingCorrelation::new(8)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn flat_return_channel_yields_zero() {
// y is constant ⇒ its returns are all zero ⇒ undefined ⇒ 0.
let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
let last = RollingCorrelation::new(6)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|i| {
let t = f64::from(i);
(100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
})
.collect();
let mut rc = RollingCorrelation::new(20).unwrap();
for v in rc.batch(&pairs).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v));
}
}
#[test]
fn reset_clears_state() {
let mut rc = RollingCorrelation::new(4).unwrap();
rc.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
assert!(rc.is_ready());
rc.reset();
assert!(!rc.is_ready());
assert_eq!(rc.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
(t.sin(), (t * 0.5).cos())
})
.collect();
let batch = RollingCorrelation::new(14).unwrap().batch(&pairs);
let mut rc = RollingCorrelation::new(14).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,243 @@
//! Rolling covariance of the period-over-period *returns* of two series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rolling covariance of the **returns** of two synchronised series.
///
/// Each `update` takes one `(x, y)` level pair, differences each channel into a
/// one-step return, and reports the population covariance of those returns over
/// the trailing window of `period` return pairs:
///
/// ```text
/// rxₜ = xₜ xₜ₋₁ ryₜ = yₜ − yₜ₋₁
/// cov = (1/n) · Σ rx·ry r̄x · r̄y
/// ```
///
/// Unlike [`crate::RollingCorrelation`] the result is **not** normalised to
/// `[1, 1]`: it carries the units of the two return streams multiplied
/// together, so it scales with volatility. It is the raw building block behind
/// correlation, beta and portfolio variance — positive when the two return
/// streams tend to move the same way, negative when they offset.
///
/// Each `update` is O(1): three running sums (`Σrx`, `Σry`, `Σrxry`) are
/// maintained as the window slides. The first level in each channel produces no
/// return, so a `period`-pair covariance needs `period + 1` updates of warmup.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, RollingCovariance};
///
/// let mut rc = RollingCovariance::new(5).unwrap();
/// let mut last = None;
/// for i in 0..20 {
/// let x = f64::from(i);
/// last = rc.update((x, 3.0 * x)); // y's return is 3× x's return
/// }
/// // cov(rx, ry) = cov(1, 3) over constant unit returns = 3 · var(rx) = 0
/// // for a constant return; use a varying path in practice. Here returns are
/// // constant (1 and 3) ⇒ covariance 0.
/// assert!(last.unwrap().abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct RollingCovariance {
period: usize,
prev: Option<(f64, f64)>,
window: VecDeque<(f64, f64)>,
sum_x: f64,
sum_y: f64,
sum_xy: f64,
}
impl RollingCovariance {
/// Construct a new rolling return-covariance.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — covariance is
/// undefined for fewer than two return pairs.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "rolling covariance needs period >= 2",
});
}
Ok(Self {
period,
prev: None,
window: VecDeque::with_capacity(period),
sum_x: 0.0,
sum_y: 0.0,
sum_xy: 0.0,
})
}
/// Configured window of returns.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for RollingCovariance {
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_xy -= ox * oy;
}
self.window.push_back((rx, ry));
self.sum_x += rx;
self.sum_y += ry;
self.sum_xy += rx * ry;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let mean_x = self.sum_x / n;
let mean_y = self.sum_y / n;
Some(self.sum_xy / n - mean_x * mean_y)
}
fn reset(&mut self) {
self.prev = None;
self.window.clear();
self.sum_x = 0.0;
self.sum_y = 0.0;
self.sum_xy = 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 {
"RollingCovariance"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_two() {
assert!(RollingCovariance::new(0).is_err());
assert!(RollingCovariance::new(1).is_err());
assert!(RollingCovariance::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let rc = RollingCovariance::new(14).unwrap();
assert_eq!(rc.period(), 14);
assert_eq!(rc.warmup_period(), 15);
assert_eq!(rc.name(), "RollingCovariance");
assert!(!rc.is_ready());
}
#[test]
fn warmup_needs_period_plus_one() {
let mut rc = RollingCovariance::new(3).unwrap();
assert_eq!(rc.update((1.0, 1.0)), None);
assert_eq!(rc.update((2.0, 3.0)), None);
assert_eq!(rc.update((3.0, 5.0)), None);
assert!(rc.update((4.0, 7.0)).is_some());
assert!(rc.is_ready());
}
#[test]
fn hand_computed_value() {
// Levels x = 0,1,3,6,10 ⇒ returns 1,2,3,4; y = 2x ⇒ returns 2,4,6,8.
// With period = 3 the final window is rx = [2,3,4], ry = [4,6,8]:
// Σrx·ry/3 = 58/3, r̄x·r̄y = 3·6 = 18 ⇒ cov = 58/3 18 = 4/3.
let pairs = [
(0.0, 0.0),
(1.0, 2.0),
(3.0, 6.0),
(6.0, 12.0),
(10.0, 20.0),
];
let last = RollingCovariance::new(3)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 4.0 / 3.0, epsilon = 1e-9);
}
#[test]
fn opposing_returns_give_negative_covariance() {
let pairs: Vec<(f64, f64)> = (0..30)
.map(|i| {
let x = (f64::from(i) * 0.4).sin() * 10.0;
(x, -x)
})
.collect();
let last = RollingCovariance::new(10)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last < 0.0, "cov {last}");
}
#[test]
fn flat_channel_gives_zero() {
let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
let last = RollingCovariance::new(6)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut rc = RollingCovariance::new(4).unwrap();
rc.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 1.0), (4.0, 9.0), (5.0, 2.0)]);
assert!(rc.is_ready());
rc.reset();
assert!(!rc.is_ready());
assert_eq!(rc.update((1.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|i| {
let t = f64::from(i);
(t.sin() * 4.0, (t * 0.5).cos() * 2.0)
})
.collect();
let batch = RollingCovariance::new(12).unwrap().batch(&pairs);
let mut rc = RollingCovariance::new(12).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,278 @@
//! Bollinger bands on the spread of two series, for pairs mean-reversion trading.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Output of [`SpreadBollingerBands`].
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpreadBollingerBandsOutput {
/// Middle band: the rolling mean of the spread.
pub middle: f64,
/// Upper band: `middle + num_std · σ`.
pub upper: f64,
/// Lower band: `middle num_std · σ`.
pub lower: f64,
/// `%b`: where the current spread sits across the band, `(s lower) /
/// (upper lower)`. `0` is the lower band, `1` the upper, `0.5` the middle.
/// Reported as `0.5` when the band has zero width (a flat spread).
pub percent_b: f64,
}
/// Bollinger bands on the spread `a b` of two series.
///
/// Each `update` takes one `(a, b)` price pair and forms the spread
/// `sₜ = aₜ bₜ`. Over the trailing window of `period` spreads it builds a
/// classic Bollinger envelope:
///
/// ```text
/// middle = mean(s) σ = stddev(s)
/// upper = middle + num_std · σ
/// lower = middle num_std · σ
/// %b = (s_now lower) / (upper lower)
/// ```
///
/// Applied to a spread rather than a price, the bands are a ready-made pairs
/// mean-reversion signal: the spread riding the **upper** band is stretched
/// rich (a short-the-spread setup), the **lower** band stretched cheap, and a
/// return to the **middle** is the exit. `%b` compresses the location into one
/// number for thresholding. The spread is the raw difference `a b`, so feed
/// already-comparable legs (e.g. a hedged pair, two yields, or log prices); pair
/// this with [`crate::BetaNeutralSpread`] when the legs need a hedge ratio first.
///
/// A flat spread yields a zero-width band; `%b` is then reported as the neutral
/// `0.5`. Each `update` is `O(1)`: the mean and variance come from two running
/// sums maintained as the window slides.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SpreadBollingerBands};
///
/// let mut bb = SpreadBollingerBands::new(20, 2.0).unwrap();
/// let mut last = None;
/// for t in 0..60 {
/// let b = 100.0 + f64::from(t);
/// let a = b + 2.0 * (f64::from(t) * 0.5).sin();
/// last = bb.update((a, b));
/// }
/// let out = last.unwrap();
/// assert!(out.lower <= out.middle && out.middle <= out.upper);
/// ```
#[derive(Debug, Clone)]
pub struct SpreadBollingerBands {
period: usize,
num_std: f64,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
}
impl SpreadBollingerBands {
/// Construct new spread Bollinger bands.
///
/// `period` is the look-back window; `num_std` is the band width in standard
/// deviations.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or
/// [`Error::InvalidParameter`] if `num_std` is not strictly positive (and
/// finite).
pub fn new(period: usize, num_std: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "spread bollinger bands needs period >= 2",
});
}
if !num_std.is_finite() || num_std <= 0.0 {
return Err(Error::InvalidParameter {
message: "spread bollinger bands needs num_std > 0",
});
}
Ok(Self {
period,
num_std,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
})
}
/// Configured look-back window.
pub const fn period(&self) -> usize {
self.period
}
/// Configured band width in standard deviations.
pub const fn num_std(&self) -> f64 {
self.num_std
}
}
impl Indicator for SpreadBollingerBands {
type Input = (f64, f64);
type Output = SpreadBollingerBandsOutput;
fn update(&mut self, input: (f64, f64)) -> Option<SpreadBollingerBandsOutput> {
let (a, b) = input;
let spread = a - b;
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(spread);
self.sum += spread;
self.sum_sq += spread * spread;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let middle = self.sum / n;
let variance = (self.sum_sq / n - middle * middle).max(0.0);
let sigma = variance.sqrt();
let half_width = self.num_std * sigma;
let upper = middle + half_width;
let lower = middle - half_width;
let percent_b = if half_width == 0.0 {
0.5
} else {
(spread - lower) / (upper - lower)
};
Some(SpreadBollingerBandsOutput {
middle,
upper,
lower,
percent_b,
})
}
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 {
"SpreadBollingerBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_bad_parameters() {
assert!(SpreadBollingerBands::new(1, 2.0).is_err());
assert!(SpreadBollingerBands::new(20, 0.0).is_err());
assert!(SpreadBollingerBands::new(20, -1.0).is_err());
assert!(SpreadBollingerBands::new(20, f64::NAN).is_err());
assert!(SpreadBollingerBands::new(2, 2.0).is_ok());
}
#[test]
fn accessors_and_metadata() {
let bb = SpreadBollingerBands::new(20, 2.5).unwrap();
assert_eq!(bb.period(), 20);
assert_eq!(bb.num_std(), 2.5);
assert_eq!(bb.warmup_period(), 20);
assert_eq!(bb.name(), "SpreadBollingerBands");
assert!(!bb.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut bb = SpreadBollingerBands::new(3, 2.0).unwrap();
assert_eq!(bb.update((1.0, 0.0)), None);
assert_eq!(bb.update((2.0, 0.0)), None);
assert!(bb.update((3.0, 0.0)).is_some());
assert!(bb.is_ready());
}
#[test]
fn hand_computed_value() {
// Spreads 1,2,3,4 (b = 0), period 4, num_std 2:
// mean = 2.5, σ = √1.25, upper = 2.5 + 2√1.25, lower = 2.5 2√1.25,
// %b at s = 4 ⇒ 0.8354102.
let pairs = [(1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0)];
let out = SpreadBollingerBands::new(4, 2.0)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.middle, 2.5, epsilon = 1e-9);
assert_relative_eq!(out.upper, 4.736_067_977_499_79, epsilon = 1e-9);
assert_relative_eq!(out.lower, 0.263_932_022_500_21, epsilon = 1e-9);
assert_relative_eq!(out.percent_b, 0.835_410_196_624_97, epsilon = 1e-9);
}
#[test]
fn flat_spread_collapses_band() {
// a b constant ⇒ σ = 0 ⇒ upper = middle = lower, %b = 0.5.
let pairs: Vec<(f64, f64)> = (0..10)
.map(|t| (5.0 + f64::from(t), f64::from(t)))
.collect();
let out = SpreadBollingerBands::new(5, 2.0)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(out.upper, out.middle, epsilon = 1e-12);
assert_relative_eq!(out.lower, out.middle, epsilon = 1e-12);
assert_relative_eq!(out.percent_b, 0.5, epsilon = 1e-12);
}
#[test]
fn bands_are_ordered() {
let pairs: Vec<(f64, f64)> = (0..80)
.map(|t| {
let b = 100.0 + f64::from(t);
(b + 3.0 * (f64::from(t) * 0.4).sin(), b)
})
.collect();
let mut bb = SpreadBollingerBands::new(20, 2.0).unwrap();
for out in bb.batch(&pairs).into_iter().flatten() {
assert!(out.lower <= out.middle && out.middle <= out.upper);
}
}
#[test]
fn reset_clears_state() {
let mut bb = SpreadBollingerBands::new(4, 2.0).unwrap();
bb.batch(&[(1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0), (5.0, 0.0)]);
assert!(bb.is_ready());
bb.reset();
assert!(!bb.is_ready());
assert_eq!(bb.update((1.0, 0.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..60)
.map(|t| {
let b = 30.0 + 0.7 * f64::from(t);
(b + (f64::from(t) * 0.4).sin() * 1.5, b)
})
.collect();
let batch = SpreadBollingerBands::new(15, 2.0).unwrap().batch(&pairs);
let mut bb = SpreadBollingerBands::new(15, 2.0).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| bb.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,271 @@
//! Hurst exponent of the spread of two series, for pairs-trading regime detection.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Hurst exponent of the spread `a b` over a rolling window.
///
/// Each `update` takes one `(a, b)` price pair and forms the spread
/// `sₜ = aₜ bₜ`. Over the trailing window of `period` spreads the indicator
/// estimates the Hurst exponent `H` from how the variance of `τ`-lagged
/// differences grows with the lag `τ`:
///
/// ```text
/// V(τ) = mean_t (s_{t+τ} s_t)² ∝ τ^(2H)
/// H = slope of log V(τ) on log τ, divided by two
/// ```
///
/// `H` classifies the spread's regime:
///
/// * `H < 0.5` — **mean-reverting** (anti-persistent): the spread snaps back,
/// the regime pairs traders want.
/// * `H ≈ 0.5` — a **random walk**: no exploitable structure.
/// * `H > 0.5` — **trending** (persistent): the spread keeps diverging.
///
/// The fit uses lags `1..=period/4` (at least two). When the spread is flat —
/// every lagged difference is zero, so the log-regression has fewer than two
/// usable points — the indicator returns the neutral `0.5`. The output is
/// clamped to `[0, 1]`.
///
/// Each `update` is `O(period · period/4)`, bounded by the fixed window.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SpreadHurst};
///
/// let mut h = SpreadHurst::new(60).unwrap();
/// let mut last = None;
/// for t in 0..200 {
/// let b = 100.0 + f64::from(t);
/// // A tight oscillating spread is anti-persistent ⇒ H < 0.5.
/// let a = b + 3.0 * (f64::from(t) * 0.8).sin();
/// last = h.update((a, b));
/// }
/// assert!(last.unwrap() < 0.5);
/// ```
#[derive(Debug, Clone)]
pub struct SpreadHurst {
period: usize,
max_lag: usize,
window: VecDeque<f64>,
}
impl SpreadHurst {
/// Construct a new spread Hurst estimator.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 8` — fewer than eight
/// spreads cannot support a two-lag loglog regression.
pub fn new(period: usize) -> Result<Self> {
if period < 8 {
return Err(Error::InvalidPeriod {
message: "spread Hurst needs period >= 8",
});
}
Ok(Self {
period,
max_lag: (period / 4).max(2),
window: VecDeque::with_capacity(period),
})
}
/// Configured look-back window of spreads.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for SpreadHurst {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(a - b);
if self.window.len() < self.period {
return None;
}
let spreads: Vec<f64> = self.window.iter().copied().collect();
// Collect (log τ, log V(τ)) for every lag whose variance is positive.
let mut log_lag = Vec::with_capacity(self.max_lag);
let mut log_var = Vec::with_capacity(self.max_lag);
for lag in 1..=self.max_lag {
let mut sum_sq = 0.0;
let mut count = 0.0;
for pair in spreads.windows(lag + 1) {
let diff = pair[lag] - pair[0];
sum_sq += diff * diff;
count += 1.0;
}
let var = sum_sq / count;
if var > 0.0 {
log_lag.push((lag as f64).ln());
log_var.push(var.ln());
}
}
if log_lag.len() < 2 {
// Degenerate (flat) spread: report the random-walk midpoint.
return Some(0.5);
}
let n = log_lag.len() as f64;
let mean_lag = log_lag.iter().sum::<f64>() / n;
let mean_var = log_var.iter().sum::<f64>() / n;
let mut cov = 0.0;
let mut var_lag = 0.0;
for (lx, lv) in log_lag.iter().zip(&log_var) {
cov += (lx - mean_lag) * (lv - mean_var);
var_lag += (lx - mean_lag) * (lx - mean_lag);
}
// `log_lag` holds at least two *distinct* lag logarithms, so the lag
// variance is strictly positive — no degenerate-slope guard is needed.
let slope = cov / var_lag;
Some((slope / 2.0).clamp(0.0, 1.0))
}
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 {
"SpreadHurst"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_period_below_eight() {
assert!(SpreadHurst::new(7).is_err());
assert!(SpreadHurst::new(8).is_ok());
}
#[test]
fn accessors_and_metadata() {
let h = SpreadHurst::new(40).unwrap();
assert_eq!(h.period(), 40);
assert_eq!(h.warmup_period(), 40);
assert_eq!(h.name(), "SpreadHurst");
assert!(!h.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut h = SpreadHurst::new(8).unwrap();
for t in 0..7 {
assert_eq!(h.update((f64::from(t), 0.0)), None);
}
assert!(h.update((7.0, 0.0)).is_some());
assert!(h.is_ready());
}
#[test]
fn oscillating_spread_is_anti_persistent() {
let pairs: Vec<(f64, f64)> = (0..200)
.map(|t| {
let b = 100.0 + f64::from(t);
(b + 3.0 * (f64::from(t) * 0.8).sin(), b)
})
.collect();
let last = SpreadHurst::new(60)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last < 0.5, "H {last}");
}
#[test]
fn linear_trend_spread_is_persistent() {
// Spread = a b = t ⇒ τ-lagged differences are all τ ⇒ V(τ) = τ² ⇒ H = 1.
let pairs: Vec<(f64, f64)> = (0..40)
.map(|t| (2.0 * f64::from(t), f64::from(t)))
.collect();
let last = SpreadHurst::new(20)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
}
#[test]
fn flat_spread_returns_midpoint() {
// a b is constant ⇒ all lagged differences zero ⇒ neutral 0.5.
let pairs: Vec<(f64, f64)> = (0..30)
.map(|t| (5.0 + f64::from(t), f64::from(t)))
.collect();
let last = SpreadHurst::new(16)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.5, epsilon = 1e-12);
}
#[test]
fn output_in_unit_range() {
let pairs: Vec<(f64, f64)> = (0..150)
.map(|t| {
let b = 50.0 + 0.3 * f64::from(t);
(
b + (f64::from(t) * 0.5).sin() * 2.0 + (f64::from(t) * 0.13).cos(),
b,
)
})
.collect();
let mut h = SpreadHurst::new(48).unwrap();
for v in h.batch(&pairs).into_iter().flatten() {
assert!((0.0..=1.0).contains(&v));
}
}
#[test]
fn reset_clears_state() {
let mut h = SpreadHurst::new(8).unwrap();
for t in 0..12 {
h.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
}
assert!(h.is_ready());
h.reset();
assert!(!h.is_ready());
assert_eq!(h.update((1.0, 0.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..100)
.map(|t| {
let b = 30.0 + 0.7 * f64::from(t);
(b + (f64::from(t) * 0.4).sin() * 1.5, b)
})
.collect();
let batch = SpreadHurst::new(32).unwrap().batch(&pairs);
let mut h = SpreadHurst::new(32).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,267 @@
//! LoMacKinlay variance-ratio test on the spread of two series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// LoMacKinlay variance ratio of the spread `a b` at horizon `q`.
///
/// Each `update` takes one `(a, b)` price pair and forms the spread
/// `sₜ = aₜ bₜ`. Over the trailing window of `period` spreads the indicator
/// compares the variance of `q`-step changes against `q` times the variance of
/// one-step changes:
///
/// ```text
/// rₜ = sₜ sₜ₋₁ (one-step changes)
/// VR(q) = Var(Σ of q consecutive r) / (q · Var(r))
/// ```
///
/// Under a random walk the variance of returns grows linearly with the horizon,
/// so `VR(q) = 1`. Departures reveal autocorrelation structure:
///
/// * `VR(q) < 1` — **mean reversion** (negatively autocorrelated changes): the
/// spread's moves partly cancel, the regime pairs traders exploit.
/// * `VR(q) ≈ 1` — a **random walk**: no exploitable structure.
/// * `VR(q) > 1` — **momentum / trending** (positively autocorrelated changes).
///
/// The estimator uses overlapping `q`-step windows. When the one-step changes
/// have zero variance (a flat spread) the ratio is undefined and the indicator
/// returns the null value `1`. The output is always `≥ 0`.
///
/// Each `update` is `O(period)`, bounded by the fixed window.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, VarianceRatio};
///
/// let mut vr = VarianceRatio::new(60, 2).unwrap();
/// let mut last = None;
/// for t in 0..200 {
/// let b = 100.0 + f64::from(t);
/// // A fast, choppy spread mean-reverts (negatively autocorrelated
/// // changes) ⇒ VR(2) < 1.
/// let a = b + 2.0 * (f64::from(t) * 2.5).sin();
/// last = vr.update((a, b));
/// }
/// assert!(last.unwrap() < 1.0);
/// ```
#[derive(Debug, Clone)]
pub struct VarianceRatio {
period: usize,
q: usize,
window: VecDeque<f64>,
}
impl VarianceRatio {
/// Construct a new variance-ratio test.
///
/// `period` is the look-back window of spreads; `q` is the aggregation
/// horizon (number of one-step changes summed per long-horizon change).
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `q < 2` or if `period < q + 2`
/// (which would leave fewer than two long-horizon observations).
pub fn new(period: usize, q: usize) -> Result<Self> {
if q < 2 {
return Err(Error::InvalidPeriod {
message: "variance ratio needs q >= 2",
});
}
if period < q + 2 {
return Err(Error::InvalidPeriod {
message: "variance ratio needs period >= q + 2",
});
}
Ok(Self {
period,
q,
window: VecDeque::with_capacity(period),
})
}
/// Configured look-back window of spreads.
pub const fn period(&self) -> usize {
self.period
}
/// Configured aggregation horizon `q`.
pub const fn q(&self) -> usize {
self.q
}
}
impl Indicator for VarianceRatio {
type Input = (f64, f64);
type Output = f64;
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
let (a, b) = input;
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(a - b);
if self.window.len() < self.period {
return None;
}
let spreads: Vec<f64> = self.window.iter().copied().collect();
// One-step changes.
let returns: Vec<f64> = spreads.windows(2).map(|w| w[1] - w[0]).collect();
let m = returns.len() as f64;
let mean = returns.iter().sum::<f64>() / m;
let var_one = returns.iter().map(|r| (r - mean) * (r - mean)).sum::<f64>() / m;
if var_one <= 0.0 {
// Flat spread: the random-walk null value.
return Some(1.0);
}
// Overlapping q-step changes; their mean is q·mean by construction.
let q_mean = self.q as f64 * mean;
let long: Vec<f64> = returns.windows(self.q).map(|w| w.iter().sum()).collect();
let count = long.len() as f64;
let var_q = long
.iter()
.map(|y| (y - q_mean) * (y - q_mean))
.sum::<f64>()
/ count;
Some(var_q / (self.q as f64 * var_one))
}
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 {
"VarianceRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_bad_parameters() {
assert!(VarianceRatio::new(10, 1).is_err()); // q must be >= 2
assert!(VarianceRatio::new(3, 2).is_err()); // period must be >= q + 2
assert!(VarianceRatio::new(4, 2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let vr = VarianceRatio::new(60, 4).unwrap();
assert_eq!(vr.period(), 60);
assert_eq!(vr.q(), 4);
assert_eq!(vr.warmup_period(), 60);
assert_eq!(vr.name(), "VarianceRatio");
assert!(!vr.is_ready());
}
#[test]
fn warmup_returns_none() {
let mut vr = VarianceRatio::new(4, 2).unwrap();
assert_eq!(vr.update((1.0, 0.0)), None);
assert_eq!(vr.update((2.0, 0.0)), None);
assert_eq!(vr.update((3.0, 0.0)), None);
assert!(vr.update((4.0, 0.0)).is_some());
assert!(vr.is_ready());
}
#[test]
fn alternating_changes_give_zero_ratio() {
// Spreads 0,2,1,3,2 ⇒ changes 2,-1,2,-1; q = 2 overlapping sums are all
// 1 (constant) ⇒ Var(q) = 0 ⇒ VR = 0 (perfect mean reversion).
let pairs = [(0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (3.0, 0.0), (2.0, 0.0)];
let last = VarianceRatio::new(5, 2)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn oscillating_spread_is_below_one() {
let pairs: Vec<(f64, f64)> = (0..200)
.map(|t| {
let b = 100.0 + f64::from(t);
(b + 2.0 * (f64::from(t) * 2.5).sin(), b)
})
.collect();
let last = VarianceRatio::new(60, 2)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert!(last < 1.0, "VR {last}");
}
#[test]
fn flat_spread_returns_one() {
let pairs: Vec<(f64, f64)> = (0..30)
.map(|t| (5.0 + f64::from(t), f64::from(t)))
.collect();
let last = VarianceRatio::new(10, 3)
.unwrap()
.batch(&pairs)
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(last, 1.0);
}
#[test]
fn output_non_negative() {
let pairs: Vec<(f64, f64)> = (0..150)
.map(|t| {
let b = 50.0 + 0.3 * f64::from(t);
(b + (f64::from(t) * 0.5).sin() * 2.0, b)
})
.collect();
let mut vr = VarianceRatio::new(40, 4).unwrap();
for v in vr.batch(&pairs).into_iter().flatten() {
assert!(v >= 0.0, "VR {v}");
}
}
#[test]
fn reset_clears_state() {
let mut vr = VarianceRatio::new(6, 2).unwrap();
for t in 0..12 {
vr.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
}
assert!(vr.is_ready());
vr.reset();
assert!(!vr.is_ready());
assert_eq!(vr.update((1.0, 0.0)), None);
}
#[test]
fn batch_equals_streaming() {
let pairs: Vec<(f64, f64)> = (0..100)
.map(|t| {
let b = 30.0 + 0.7 * f64::from(t);
(b + (f64::from(t) * 0.4).sin() * 1.5, b)
})
.collect();
let batch = VarianceRatio::new(32, 3).unwrap().batch(&pairs);
let mut vr = VarianceRatio::new(32, 3).unwrap();
let streamed: Vec<_> = pairs.iter().map(|p| vr.update(*p)).collect();
assert_eq!(batch, streamed);
}
}
+44 -39
View File
@@ -42,6 +42,7 @@
// builds — library code is still linted for genuinely large stack arrays.
#![cfg_attr(test, allow(clippy::large_stack_arrays))]
mod cross_section;
mod derivatives;
mod error;
mod microstructure;
@@ -50,21 +51,23 @@ mod traits;
pub mod indicators;
pub use cross_section::{CrossSection, Member};
pub use derivatives::DerivativesTick;
pub use error::{Error, Result};
pub use indicators::{
AbandonedBaby, AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator,
AdaptiveCycle, Adl, AdvanceBlock, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma,
Alpha, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands,
AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, AvgPrice, AwesomeOscillator,
AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta, BollingerBands, BollingerBandwidth,
BollingerOutput, Breakaway, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci,
CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots,
ClassicPivotsOutput, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration,
CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock,
Counterattack, CumulativeVolumeDelta, CyberneticCycle, Decycler, DecyclerOscillator, Dema,
DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, Doji, DojiStar,
AdaptiveCycle, Adl, AdvanceBlock, AdvanceDecline, Adx, AdxOutput, Adxr, Alligator,
AlligatorOutput, Alma, Alpha, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator,
AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown,
AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta,
BetaNeutralSpread, BollingerBands, BollingerBandwidth, BollingerOutput, Breakaway,
CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo,
ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput,
ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput,
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack,
CumulativeVolumeDelta, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DemandIndex,
DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar,
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx,
EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
@@ -72,30 +75,32 @@ pub use indicators::{
FibonacciPivots, FibonacciPivotsOutput, FisherTransform, Footprint, FootprintOutput,
ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate,
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility,
GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator,
HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma,
HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput,
HiLoActivator, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility,
Hma, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
InverseFisherTransform, InvertedHammer, Jma, KagiBars, Kama, KellyCriterion, Keltner,
KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda,
LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression,
LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongLine, LongShortRatio,
MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, MidPoint, MidPrice,
MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, Nvi, OIPriceDivergence,
OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange,
OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN,
PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB,
PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo,
ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread, RecoveryFactor,
RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan,
RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollingVwap,
RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeparatingLines, SharpeRatio,
ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, Sma, Smi, Smma, SortinoRatio,
SpearmanCorrelation, SpinningTop, StalledPattern, StandardError, StandardErrorBands,
InverseFisherTransform, InvertedHammer, Jma, KagiBars, KalmanHedgeRatio,
KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength,
Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput,
LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput,
LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix,
MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex,
MatHold, MatchingLow, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi,
Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar,
Natr, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta,
OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
OrderBookImbalanceTop1, OrderBookImbalanceTopN, OuHalfLife, PainIndex, PairSpreadZScore,
PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo,
PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi,
QuotedSpread, RSquared, RealizedSpread, RecoveryFactor, RelativeStrengthAB,
RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc,
Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollingCorrelation, RollingCovariance,
RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeparatingLines,
SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, Sma, Smi, Smma,
SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadBollingerBands,
SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker,
@@ -106,12 +111,12 @@ pub use indicators::{
TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsf, Tsi, Tsv,
TtmSqueeze, TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput,
ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator,
VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vwap,
VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose,
WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput,
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
Zlema, FAMILIES, T3,
ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop,
VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput,
Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput,
WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots,
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and
+1 -1
View File
@@ -8,7 +8,7 @@ That includes:
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node), and
[WASM](https://docs.wickra.org/Quickstart-WASM).
- A per-indicator deep dive for every one of the **314 indicators** across
- A per-indicator deep dive for every one of the **325 indicators** across
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
+7 -7
View File
@@ -17,7 +17,7 @@
},
"../../bindings/node": {
"name": "wickra",
"version": "0.4.6",
"version": "0.4.7",
"license": "PolyForm-Noncommercial-1.0.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -26,12 +26,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.4.6",
"wickra-darwin-x64": "0.4.6",
"wickra-linux-arm64-gnu": "0.4.6",
"wickra-linux-x64-gnu": "0.4.6",
"wickra-win32-arm64-msvc": "0.4.6",
"wickra-win32-x64-msvc": "0.4.6"
"wickra-darwin-arm64": "0.4.7",
"wickra-darwin-x64": "0.4.7",
"wickra-linux-arm64-gnu": "0.4.7",
"wickra-linux-x64-gnu": "0.4.7",
"wickra-win32-arm64-msvc": "0.4.7",
"wickra-win32-x64-msvc": "0.4.7"
}
},
"node_modules/wickra": {
+7
View File
@@ -80,6 +80,13 @@ test = false
doc = false
bench = false
[[bin]]
name = "indicator_update_crosssection"
path = "fuzz_targets/indicator_update_crosssection.rs"
test = false
doc = false
bench = false
[[bin]]
name = "tick_aggregator"
path = "fuzz_targets/tick_aggregator.rs"
@@ -0,0 +1,47 @@
#![no_main]
//! Fuzz market-breadth `Indicator<Input = CrossSection>` implementations with
//! arbitrary cross-section streams.
//!
//! Each iteration consumes a byte stream, interprets it as a sequence of `f64`
//! values (8 bytes each), packs consecutive pairs into [`Member`]s (a `change`
//! and a `volume`, with the high/low flags taken from the value bit parity), and
//! groups the members into bounded-size [`CrossSection`] ticks. Cross-sections
//! are built with `new_unchecked` so the fuzzer can explore degenerate values
//! (non-finite changes, negative volumes, empty-adjacent groups) that the
//! validating constructor would reject — the indicators must never panic,
//! streaming or batched.
use libfuzzer_sys::fuzz_target;
use wickra_core::{AdvanceDecline, BatchExt, CrossSection, Indicator, Member};
#[inline(never)]
fn drive<I>(make: impl Fn() -> I, sections: &[CrossSection])
where
I: Indicator<Input = CrossSection, Output = f64> + BatchExt,
{
let mut streaming = make();
for section in sections {
let _ = streaming.update(section.clone());
}
let _ = make().batch(sections);
}
fuzz_target!(|data: &[u8]| {
let floats: Vec<f64> = data
.chunks_exact(8)
.map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes")))
.collect();
let members: Vec<Member> = floats
.chunks_exact(2)
.map(|c| Member::new(c[0], c[1], c[0].to_bits() & 1 == 1, c[1].to_bits() & 1 == 1))
.collect();
// Group members into cross-sections of up to eight symbols each so a single
// input yields a stream of ragged universes.
let sections: Vec<CrossSection> = members
.chunks(8)
.filter(|chunk| !chunk.is_empty())
.map(|chunk| CrossSection::new_unchecked(chunk.to_vec(), 0))
.collect();
drive(AdvanceDecline::new, &sections);
});
+21 -4
View File
@@ -8,10 +8,7 @@
//! panic.
use libfuzzer_sys::fuzz_target;
use wickra_core::{
Alpha, BatchExt, Cointegration, Indicator, InformationRatio, LeadLagCrossCorrelation,
PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, TreynorRatio,
};
use wickra_core::{Alpha, BatchExt, BetaNeutralSpread, Cointegration, DistanceSsd, GrangerCausality, Indicator, InformationRatio, KalmanHedgeRatio, LeadLagCrossCorrelation, OuHalfLife, PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, RollingCorrelation, RollingCovariance, SpreadBollingerBands, SpreadHurst, TreynorRatio, VarianceRatio};
#[inline(never)]
fn drive<I>(make: impl Fn() -> I, data: &[(f64, f64)])
@@ -41,6 +38,14 @@ fuzz_target!(|data: &[u8]| {
drive(|| Alpha::new(10, 0.0).unwrap(), &pairs);
drive(|| PairwiseBeta::new(10).unwrap(), &pairs);
drive(|| PairSpreadZScore::new(10, 10).unwrap(), &pairs);
drive(|| RollingCorrelation::new(20).unwrap(), &pairs);
drive(|| RollingCovariance::new(20).unwrap(), &pairs);
drive(|| OuHalfLife::new(60).unwrap(), &pairs);
drive(|| SpreadHurst::new(60).unwrap(), &pairs);
drive(|| DistanceSsd::new(20).unwrap(), &pairs);
drive(|| BetaNeutralSpread::new(20).unwrap(), &pairs);
drive(|| VarianceRatio::new(60, 2).unwrap(), &pairs);
drive(|| GrangerCausality::new(60, 1).unwrap(), &pairs);
// Struct-output pair indicator: drive update + batch directly (the generic
// `drive` above only covers `Output = f64`).
@@ -61,4 +66,16 @@ fuzz_target!(|data: &[u8]| {
let _ = rs.update(x);
}
let _ = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs);
let mut kalman_hedge_ratio = KalmanHedgeRatio::new(0.001, 0.001).unwrap();
for &x in &pairs {
let _ = kalman_hedge_ratio.update(x);
}
let _ = KalmanHedgeRatio::new(0.001, 0.001).unwrap().batch(&pairs);
let mut spread_bollinger_bands = SpreadBollingerBands::new(20, 2.0).unwrap();
for &x in &pairs {
let _ = spread_bollinger_bands.update(x);
}
let _ = SpreadBollingerBands::new(20, 2.0).unwrap().batch(&pairs);
});