Compare commits

...

12 Commits

Author SHA1 Message Date
kingchenc 099ae66b57 release: bump 0.4.7 -> 0.5.0 (#159)
Version bump for the 0.5.0 release, which ships the relicense to MIT OR Apache-2.0.

Stacked on #158 (base branch `chore/relicense-mit-apache`) so this PR's diff is the bump only. After #158 merges to main, GitHub retargets this PR to main; merge it, then tag `v0.5.0` to publish.

## Changes
- Bump 0.4.7 -> 0.5.0 across the Cargo workspace, Python `pyproject.toml`, Node `package.json` + 6 platform manifests + 2 lockfiles, and `Cargo.lock`.
- CHANGELOG: cut the [0.5.0] section (the relicense) and add compare URLs.
- SECURITY.md: supported versions 0.4.x -> 0.5.x.

Minor (not patch) bump: a relicense is a significant change. No code changes.

NOTE: do not tag/release until you give the go (irreversible publish to crates.io/PyPI/npm). Suggested merge order: #158 -> this -> tag `v0.5.0` -> then the downstream PRs.
2026-06-03 18:53:23 +02:00
kingchenc 11dd659b5f Relicense from PolyForm Noncommercial to MIT OR Apache-2.0 (#158)
Relicenses Wickra from PolyForm Noncommercial 1.0.0 to the dual, OSI-approved **MIT OR Apache-2.0** (the de-facto Rust convention). Wickra becomes permissive, commercial-use-permitted open source; users may choose either license.

## Changes
- Replace `LICENSE` (PolyForm) with `LICENSE-MIT` + `LICENSE-APACHE` (full texts).
- Cargo: workspace `license = "MIT OR Apache-2.0"` (SPDX) + all 7 sub-crates switched from `license-file.workspace` to `license.workspace`.
- `deny.toml`: drop PolyForm from the allowlist.
- Python: `pyproject.toml` PEP 639 SPDX expression; remove the non-commercial classifier (verified: sdist metadata emits `License-Expression: MIT OR Apache-2.0`).
- Node: `package.json`, the 6 platform manifests and both lockfiles.
- README + Python/Node/WASM binding READMEs, CONTRIBUTING, CITATION.cff, PR template, and the WASM `pkg.license` step in `release.yml`.
- SECURITY.md: refresh supported versions 0.1.x -> 0.4.x.
- CHANGELOG: note the relicense under [Unreleased].

## Notes
- No code changes; metadata/text only. `cargo build` and `cargo deny check licenses` pass locally.
- GitHub will auto-detect "MIT, Apache-2.0" once this lands (currently NOASSERTION).
- Matching downstream changes (org `.github` profile, webpage, docs) are in separate PRs; merge those together with the relicense release so the live sites and org profile do not claim MIT before the packages do.
2026-06-03 18:49:39 +02:00
kingchenc c096943bdf feat(breadth): complete the Market Breadth family (14 indicators) (#157)
Completes expansion-roadmap block **A2 — Market Breadth**: the 14 indicators that remained after the `AdvanceDecline` bootstrap, all built on the existing `CrossSection` input.

## Indicators (all scalar `Indicator<Input = CrossSection, Output = f64>`)

| Indicator | Reading |
|-----------|---------|
| `AdvanceDeclineRatio` | advancers / decliners |
| `AdVolumeLine` | cumulative net advancing volume |
| `McClellanOscillator` | 19/39 EMAs of ratio-adjusted net advances |
| `McClellanSummationIndex` | running total of the oscillator |
| `Trin` (Arms Index) | A/D ratio over up/down volume ratio |
| `BreadthThrust` (Zweig) | SMA of the advancing-issues share |
| `NewHighsNewLows` | new highs − new lows |
| `HighLowIndex` | SMA of the record-high percent |
| `PercentAboveMa` | % of the universe above its MA |
| `UpDownVolumeRatio` | advancing / declining volume |
| `BullishPercentIndex` | % on a point-and-figure buy signal |
| `CumulativeVolumeIndex` | volume-normalised cumulative net advancing volume |
| `AbsoluteBreadthIndex` | \|advancers − decliners\| |
| `TickIndex` | instantaneous net advancers − decliners |

## Input model

`AdVolumeLine` and `CumulativeVolumeIndex` are kept distinct (the latter normalises each tick's net advancing volume by total volume, so it stays comparable across volume regimes). `PercentAboveMa` and `BullishPercentIndex` need a per-symbol state signal that `Member` did not carry, so `Member` gains two additive flags (`above_ma`, `on_buy_signal`) via a new `Member::with_signals` constructor; the 4-arg `Member::new` leaves both cleared, so every existing caller and binding is unchanged. `CrossSection` gains volume / new-extreme / state aggregation helpers.

## Wiring

Fully wired across the Rust core, the python/node/wasm bindings, the cross-section fuzz target, the README + docs indicator counters (325 → 339), and dedicated python/node streaming-vs-batch tests. `fmt` / `test --workspace --all-features` / `clippy --workspace -D warnings` / node build+test / pytest all green locally.
2026-06-03 17:24:33 +02:00
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
kingchenc 82d1a4fe77 release: bump 0.4.5 -> 0.4.6 (#151)
Routine patch release. Ships the **19 TA-Lib parity indicators** (DM components, price transforms, ROC ratio forms, LinReg intercept / TSF, MACDFIX / MACDEXT / SAREXT, Hilbert phasor / DC-phase / trend-mode) added in #148, with the cold-path coverage fix from #150 — indicator count **314**, repo back at 100%.

Version strings bumped `0.4.5 -> 0.4.6` 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.6] - 2026-06-03` with refreshed compare links

No code changes. `fmt` / `clippy --workspace -D warnings` green locally.
2026-06-03 02:56:00 +02:00
kingchenc f71b3b6b49 test: cover the cold paths in the TA-Lib parity batch (100% patch) (#150)
PR #148 merged at **99.67%** patch coverage — `codecov/patch` flagged seven by-construction-rare lines in three of the new indicators that no test exercised. This brings the batch back to 100%.

**`ht_dcphase` / `ht_trendmode`** (6 lines) — the dominant-cycle phase recovery guards against a near-zero imaginary part (where `atan(real/imag)` is undefined) by collapsing to ±90° on the sign of the real part. That branch is unreachable with realistic price data. Extracted the phase-unwrap arithmetic into a private `compute_dc_phase(real, imag, smooth_period)` helper — a pure refactor with byte-identical output — and unit-tested it directly with crafted `(real, imag)` pairs, covering both the ±90 collapse and the normal `atan` path.

**`sar_ext`** (1 line) — `Accel::validate`'s non-finite guard was only ever hit for non-positive terms, never non-finite ones, despite the test comment claiming both. Added `NaN` / `infinity` cases on the long and short acceleration schedules.

No behaviour or public-API change. Locally: `cargo test -p wickra-core` (2578 + 297 doctests) and `clippy --workspace -D warnings` all green.
2026-06-03 02:48:11 +02:00
kingchenc d081cb9581 docs: list the 19 TA-Lib parity indicators in the README family rows (#149)
The TA-Lib parity batch (#148) bumped the indicator counter to 314, but `sync-about` only syncs the *number* — the family-table prose in the README still listed the pre-batch set. This fills the 19 new names into their existing family rows so the catalogue matches the count.

- **Momentum Oscillators**: ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100)
- **Trend & Directional**: MACD Fixed (MACDFIX), MACD Extended (MACDEXT), Plus DM, Minus DM, Plus DI, Minus DI, DX
- **Trailing Stops**: Parabolic SAR Extended (SAREXT)
- **Price Statistics**: Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast
- **Ehlers / Cycle (DSP)**: Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode

No new family — the "nineteen families" wording and the 314 counter are untouched. Docs-only, no code changes.
2026-06-03 02:36:09 +02:00
kingchenc 9eb46f144a feat: TA-Lib parity — 19 standalone indicators (DM components, price transforms, ROC/LinReg/MACD/SAR variants, Hilbert outputs) (#148)
Closes the remaining TA-Lib function-name gap by shipping each missing or
bundled-only function as a real, standalone, fully-covered indicator. 19 new
indicators across 5 families; mod-count 295 -> 314.

### Trend & Directional — Directional Movement components
- `PlusDm` (`PLUS_DM`), `MinusDm` (`MINUS_DM`) — Wilder-smoothed ±DM.
- `PlusDi` (`PLUS_DI`), `MinusDi` (`MINUS_DI`) — `100·smoothed(±DM)/ATR`.
- `Dx` (`DX`) — `100·|+DI−−DI|/(+DI+−DI)`.

### Price Statistics
- `AvgPrice` (`AVGPRICE`) — `(O+H+L+C)/4`.
- `MidPoint` (`MIDPOINT`) — `(max+min)/2` of a scalar series over N.
- `MidPrice` (`MIDPRICE`) — `(highestHigh+lowestLow)/2` over N.
- `LinRegIntercept` (`LINEARREG_INTERCEPT`) — OLS intercept.
- `Tsf` (`TSF`) — time series forecast `a + b·period`.

### Momentum Oscillators
- `Rocp` (`ROCP`), `Rocr` (`ROCR`), `Rocr100` (`ROCR100`) — ROC ratio forms.

### Trailing Stops
- `SarExt` (`SAREXT`) — Parabolic SAR with start value, reversal offset,
  separate long/short acceleration, signed output.

### Trend & Directional — MACD variants
- `MacdFix` (`MACDFIX`) — MACD fixed 12/26.
- `MacdExt` (`MACDEXT`) — MACD with a selectable moving-average type per line
  (new public `MaType` enum: SMA/EMA/WMA/DEMA/TEMA/TRIMA).

### Ehlers / Cycle (DSP) — Hilbert transform outputs
- `HtPhasor` (`HT_PHASOR`) — in-phase / quadrature components.
- `HtDcPhase` (`HT_DCPHASE`) — dominant-cycle phase (degrees).
- `HtTrendMode` (`HT_TRENDMODE`) — trend (1) vs cycle (0) classification.

Each indicator ships the full chain: core + every-branch unit tests, Python /
Node / WASM bindings, fuzz coverage, README counter + family rows, CHANGELOG.
`cargo test`, doctests, `clippy -D warnings`, `npm test` and pytest all green
locally; mod-count == lib-block == README counter (314), FAMILIES total 309.
2026-06-03 02:26:38 +02:00
99 changed files with 18005 additions and 455 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ Closes #
- [ ] Public API changes are reflected in `CHANGELOG.md`
- [ ] Public API changes are reflected in rustdoc / README / examples
- [ ] No `todo*.md` or other local-only notes are staged
- [ ] License header / `LICENSE` reference unchanged (PolyForm-NC-1.0.0)
- [ ] License header / `LICENSE` reference unchanged (MIT OR Apache-2.0)
## Notes for reviewers
+1 -1
View File
@@ -536,7 +536,7 @@ jobs:
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
pkg.homepage = 'https://github.com/wickra-lib/wickra';
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
pkg.license = 'PolyForm-Noncommercial-1.0.0';
pkg.license = 'MIT OR Apache-2.0';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
"
+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'
+88 -1
View File
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.0] - 2026-06-03
### Added
- **TICK Index** — instantaneous net advancing-minus-declining issues (`TICK_INDEX`).
- **Absolute Breadth Index** — absolute value of net advancing-minus-declining issues (`ABSOLUTE_BREADTH_INDEX`).
- **Cumulative Volume Index** — running total of volume-normalised net advancing volume (`CUMULATIVE_VOLUME_INDEX`).
- **Bullish Percent Index** — percentage of the universe on a point-and-figure buy signal (`BULLISH_PERCENT_INDEX`).
- **Up/Down Volume Ratio** — advancing volume divided by declining volume (`UP_DOWN_VOLUME_RATIO`).
- **Percent Above Moving Average** — percentage of the universe trading above its reference moving average (`PERCENT_ABOVE_MA`).
- **High-Low Index** — moving average of the record-high percentage (`HIGH_LOW_INDEX`).
- **New Highs - New Lows** — net count of new period highs minus new period lows (`NEW_HIGHS_NEW_LOWS`).
- **Breadth Thrust** — moving average of the advancing-issues share (Zweig) (`BREADTH_THRUST`).
- **TRIN / Arms Index** — advance-decline ratio divided by the up-down volume ratio (`TRIN`).
- **McClellan Summation Index** — running cumulative total of the McClellan Oscillator (`MCCLELLAN_SUMMATION_INDEX`).
- **McClellan Oscillator** — spread between a 19- and 39-period EMA of ratio-adjusted net advances (`MCCLELLAN_OSCILLATOR`).
- **Advance/Decline Volume Line** — cumulative net advancing-minus-declining volume across the universe (`AD_VOLUME_LINE`).
- **Advance/Decline Ratio** — advancing issues divided by declining issues across the universe (`ADVANCE_DECLINE_RATIO`).
### Changed
- **Relicensed** from PolyForm Noncommercial 1.0.0 to dual **MIT OR Apache-2.0**. Wickra is now OSI-approved, permissive open source; commercial use is permitted under either license. See [`LICENSE-MIT`](LICENSE-MIT) and [`LICENSE-APACHE`](LICENSE-APACHE).
## [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
- **TA-Lib parity — Directional Movement components** — the ADX building blocks,
previously available only bundled inside `Adx`, as standalone single-output
indicators:
- `PlusDm` (`PLUS_DM`) — Wilder-smoothed plus directional movement.
- `MinusDm` (`MINUS_DM`) — Wilder-smoothed minus directional movement.
- `PlusDi` (`PLUS_DI`) — plus directional indicator, `100 · smoothed(+DM) / ATR`.
- `MinusDi` (`MINUS_DI`) — minus directional indicator, `100 · smoothed(-DM) / ATR`.
- `Dx` (`DX`) — directional movement index, `100 · |+DI DI| / (+DI + DI)`.
- **TA-Lib parity — price transforms** — window and per-bar price aggregates:
- `MidPrice` (`MIDPRICE`) — `(highest high + lowest low) / 2` over a window.
- `MidPoint` (`MIDPOINT`) — `(max + min) / 2` of a scalar series over a window.
- `AvgPrice` (`AVGPRICE`) — per-bar `(open + high + low + close) / 4`.
- **TA-Lib parity — rate-of-change variants** — the ratio forms of `Roc`:
- `Rocp` (`ROCP`) — `(close close[period]) / close[period]` (fraction).
- `Rocr` (`ROCR`) — `close / close[period]` (ratio).
- `Rocr100` (`ROCR100`) — `close / close[period] · 100`.
- **TA-Lib parity — linear-regression outputs** — the remaining OLS endpoints:
- `LinRegIntercept` (`LINEARREG_INTERCEPT`) — the OLS intercept `a`.
- `Tsf` (`TSF`) — time series forecast, `a + b·period` (one bar ahead).
- **TA-Lib parity — `MacdFix` (`MACDFIX`)** — MACD with fast/slow fixed at 12/26
and only the signal period configurable; output is the usual `{macd, signal,
histogram}` triple.
- **TA-Lib parity — `SarExt` (`SAREXT`)** — Parabolic SAR with a start value,
reversal offset, independent long/short acceleration, and a signed output
(positive in long phases, negative in short phases).
- **TA-Lib parity — `MacdExt` (`MACDEXT`)** — MACD with an independently
selectable moving-average type (new `MaType` enum: SMA/EMA/WMA/DEMA/TEMA/TRIMA)
for each of the fast, slow and signal lines.
- **TA-Lib parity — `HtPhasor` (`HT_PHASOR`)** — the in-phase and quadrature
components of the Hilbert-transform analytic signal, as a `{inphase,
quadrature}` pair.
- **TA-Lib parity — `HtDcPhase` (`HT_DCPHASE`)** — the phase angle (in degrees)
of the Hilbert-transform dominant cycle.
- **TA-Lib parity — `HtTrendMode` (`HT_TRENDMODE`)** — Ehlers' trend (`1`) vs
cycle (`0`) classification from the Hilbert-transform dominant cycle.
## [0.4.5] - 2026-06-02
### Added
@@ -1084,7 +1168,10 @@ 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.5...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.0...HEAD
[0.5.0]: https://github.com/wickra-lib/wickra/compare/v0.4.7...v0.5.0
[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
[0.4.3]: https://github.com/wickra-lib/wickra/compare/v0.4.2...v0.4.3
+3 -1
View File
@@ -26,4 +26,6 @@ keywords:
- quantitative-finance
- rust
- time-series
license: PolyForm-Noncommercial-1.0.0
license:
- MIT
- Apache-2.0
+5 -5
View File
@@ -5,11 +5,11 @@ build the project, the standards a change must meet, and how to get it merged.
## License of contributions
Wickra is licensed under the **PolyForm Noncommercial License 1.0.0** (see
[`LICENSE`](LICENSE)). By submitting a contribution you agree that it is
licensed to the project under those same terms. The Noncommercial license
permits use for any purpose **other than** a commercial one; keep that in mind
when proposing features or depending on Wickra elsewhere.
Wickra is dual-licensed under the [MIT](LICENSE-MIT) and
[Apache-2.0](LICENSE-APACHE) licenses; users may choose either. Unless you
explicitly state otherwise, any contribution you intentionally submit for
inclusion in the work, as defined in the Apache-2.0 license, shall be dual
licensed as above, without any additional terms or conditions.
## Project layout
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.4.5"
version = "0.5.0"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.4.5"
version = "0.5.0"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.4.5"
version = "0.5.0"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.4.5"
version = "0.5.0"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.4.5"
version = "0.5.0"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.4.5"
version = "0.5.0"
dependencies = [
"console_error_panic_hook",
"js-sys",
+3 -3
View File
@@ -12,11 +12,11 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.4.5"
version = "0.5.0"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
license-file = "LICENSE"
license = "MIT OR Apache-2.0"
repository = "https://github.com/wickra-lib/wickra"
homepage = "https://github.com/wickra-lib/wickra"
readme = "README.md"
@@ -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.5" }
wickra-core = { path = "crates/wickra-core", version = "0.5.0" }
thiserror = "2"
rayon = "1.10"
-161
View File
@@ -1,161 +0,0 @@
# PolyForm Noncommercial License 1.0.0
<https://polyformproject.org/licenses/noncommercial/1.0.0>
## Acceptance
In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
## Copyright License
The licensor grants you a copyright license for the
software to do everything you might do with the software
that would otherwise infringe the licensor's copyright
in it for any permitted purpose. However, you may
only distribute the software according to [Distribution
License](#distribution-license) and make changes or new works
based on the software according to [Changes and New Works
License](#changes-and-new-works-license).
## Distribution License
The licensor grants you an additional copyright license
to distribute copies of the software. Your license to
distribute covers distributing the software with changes
and new works permitted by [Changes and New Works
License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of
the software from you also gets a copy of these terms or the
URL for them above, as well as copies of any plain-text lines
beginning with `Required Notice:` that the licensor provided
with the software. For example:
> Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
## Changes and New Works License
The licensor grants you an additional copyright license
to make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for
the benefit of public knowledge, personal study, private
entertainment, hobby projects, amateur pursuits, or religious
observance, without any anticipated commercial application,
is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution,
public research organization, public safety or health
organization, environmental protection organization, or
government institution is use for a permitted purpose regardless
of the source of funding or obligations resulting from the
funding.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have
violated any of these terms, or done anything with the software
not covered by your licenses, your licenses can nonetheless
continue if you come into full compliance with these terms,
and take practical steps to correct past violations, within 32
days of receiving notice. Otherwise, all your licenses end
immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control
of, or are under common control with that organization.
**Control** means ownership of substantially all the assets
of an entity, or the power to direct its management and
policies by vote, contract, or otherwise. Control can be
direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
## Additional Permissions Granted by the Licensor
These additional permissions supplement the PolyForm Noncommercial
License 1.0.0 above. They only broaden, and never narrow, the
licenses granted to you. The text of the PolyForm Noncommercial
License 1.0.0 above is unmodified.
Use by a natural person, acting for their own personal account and
not on behalf of any third party, is use for a permitted purpose.
This includes operating an automated trading bot or trading strategy
on that person's own capital, whether or not it earns that person
money.
For the avoidance of doubt, the licenses above already let you use,
fork, modify, and redistribute the software, and file issues and
contribute changes, for any permitted purpose. Personal projects,
research, education, nonprofit organizations, government use, and
hobby trading bots are permitted purposes.
Any other commercial use — in particular the commercial sale of the
software itself, or the commercial sale of services built around it —
requires a separate commercial license from the licensor. If you want
to use Wickra commercially, get in touch about a license at
<https://github.com/wickra-lib/wickra>.
---
Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 kingchenc and the Wickra contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kingchenc and the Wickra contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24 -16
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=295" 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=339" 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)
@@ -9,7 +9,7 @@
[![crates.io](https://img.shields.io/crates/v/wickra.svg?logo=rust&color=orange)](https://crates.io/crates/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](LICENSE)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](#license)
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/wickra-lib/wickra/badge)](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
[![Build provenance](https://img.shields.io/badge/provenance-attested-brightgreen?logo=github)](https://github.com/wickra-lib/wickra/attestations)
[![Docs](https://img.shields.io/badge/docs-docs.wickra.org-0ea5e9?logo=readthedocs&logoColor=white)](https://docs.wickra.org)
@@ -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 295 indicators; start at the
every one of the 339 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
295 streaming-first indicators across nineteen families. Every one passes the
339 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).
@@ -143,15 +143,15 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia |
| Trend & Directional | MACD, ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter |
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100) |
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
| 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, 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 |
| 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 |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| 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 |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
@@ -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, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
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 295 indicators
│ ├── wickra-core/ core engine + all 339 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -323,13 +324,20 @@ shape together before you invest the time.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
Licensed under either of
In plain English: use it, fork it, modify it, redistribute it, file issues, send
pull requests — all welcome. Personal projects, research, education, non-profits,
government, hobby trading bots: all fine. The one thing that's not allowed is
commercial sale of the software or of services built around it. If you want to
use Wickra commercially, get in touch about a license.
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
<http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
at your option. Use it, fork it, modify it, redistribute it — commercially or
not — file issues, send pull requests; all welcome.
### Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
dual licensed as above, without any additional terms or conditions.
## Disclaimer
+3 -3
View File
@@ -2,13 +2,13 @@
## Supported versions
Wickra is pre-1.0. Security fixes are applied to the latest released `0.1.x`
Wickra is pre-1.0. Security fixes are applied to the latest released `0.5.x`
version only; please upgrade to the newest release before reporting an issue.
| Version | Supported |
| --- | --- |
| 0.1.x (latest) | :white_check_mark: |
| older 0.1.x | :x: |
| 0.5.x (latest) | :white_check_mark: |
| older 0.5.x | :x: |
## Reporting a vulnerability
+1 -1
View File
@@ -9,7 +9,7 @@ edition.workspace = true
# also emits `cargo::` directives that require >= 1.77 — that older floor is
# subsumed by the 1.88 requirement now.
rust-version = "1.88"
license-file.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+3 -5
View File
@@ -3,7 +3,7 @@
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![npm](https://img.shields.io/npm/v/wickra.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](https://github.com/wickra-lib/wickra#license)
**Streaming-first technical indicators for Node.js. `npm install wickra`
prebuilt native binary, no system dependencies.**
@@ -67,7 +67,5 @@ risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
+207
View File
@@ -28,6 +28,12 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
TSF: () => new wickra.TSF(14),
LINEARREG_INTERCEPT: () => new wickra.LINEARREG_INTERCEPT(14),
ROCR100: () => new wickra.ROCR100(10),
ROCR: () => new wickra.ROCR(10),
ROCP: () => new wickra.ROCP(10),
MIDPOINT: () => new wickra.MIDPOINT(14),
SMA: () => new wickra.SMA(14),
EMA: () => new wickra.EMA(14),
WMA: () => new wickra.WMA(14),
@@ -90,6 +96,8 @@ const scalarFactories = {
EhlersStochastic: () => new wickra.EhlersStochastic(20),
EmpiricalModeDecomposition: () => new wickra.EmpiricalModeDecomposition(20, 0.5),
HilbertDominantCycle: () => new wickra.HilbertDominantCycle(),
HT_DCPHASE: () => new wickra.HT_DCPHASE(),
HT_TRENDMODE: () => new wickra.HT_TRENDMODE(),
AdaptiveCycle: () => new wickra.AdaptiveCycle(),
SineWave: () => new wickra.SineWave(),
FAMA: () => new wickra.FAMA(0.5, 0.05),
@@ -159,10 +167,17 @@ for (const [name, make] of Object.entries(scalarFactories)) {
// --- Scalar-output candle indicators: update(...) vs batch(...) ---
const candleScalar = {
MIDPRICE: { make: () => new wickra.MIDPRICE(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
DX: { make: () => new wickra.DX(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MINUS_DI: { make: () => new wickra.MINUS_DI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PLUS_DI: { make: () => new wickra.PLUS_DI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ATR: { make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PLUS_DM: { make: () => new wickra.PLUS_DM(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MINUS_DM: { make: () => new wickra.MINUS_DM(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
CCI: { make: () => new wickra.CCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
WilliamsR: { make: () => new wickra.WilliamsR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PSAR: { make: () => new wickra.PSAR(0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
SAREXT: { make: () => new wickra.SAREXT(0, 0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MFI: { make: () => new wickra.MFI(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
VWAP: { make: () => new wickra.VWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
RollingVWAP: { make: () => new wickra.RollingVWAP(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
@@ -170,6 +185,7 @@ const candleScalar = {
OBV: { make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
VWMA: { make: () => new wickra.VWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
RVI: { make: () => new wickra.RVI(10), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
AVGPRICE: { make: () => new wickra.AVGPRICE(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Inertia: { make: () => new wickra.Inertia(14, 20), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
PGO: { make: () => new wickra.PGO(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
SMI: { make: () => new wickra.SMI(5, 3, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
@@ -302,6 +318,9 @@ const multi = {
Alligator: { make: () => new wickra.Alligator(13, 8, 5), fields: ['jaw', 'teeth', 'lips'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ZeroLagMACD: { make: () => new wickra.ZeroLagMACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
HT_PHASOR: { make: () => new wickra.HT_PHASOR(), fields: ['inphase', 'quadrature'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MACDFIX: { make: () => new wickra.MACDFIX(9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MACDEXT: { make: () => new wickra.MACDEXT(12, 0, 26, 0, 9, 0), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
KST: { make: () => wickra.KST.classic(), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
BollingerBands: { make: () => new wickra.BollingerBands(20, 2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
Stochastic: { make: () => new wickra.Stochastic(14, 3), fields: ['k', 'd'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
@@ -510,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)) {
@@ -600,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;
@@ -1183,6 +1251,145 @@ 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('market breadth: 14 indicators reference values + batch parity', () => {
const flags4 = [false, false, false, false];
// Advance/Decline Ratio: 3/1 = 3 ; 0 advancers -> 0.
const adr = new wickra.AdvanceDeclineRatio();
assert.equal(adr.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4), 3.0);
assert.equal(adr.update([-1, -1, -1, -1], [10, 10, 10, 10], flags4, flags4), 0.0);
assert.deepEqual(
Array.from(
new wickra.AdvanceDeclineRatio().batch(
[[1, 1, 1, -1], [-1, -1, -1, -1]],
[[10, 10, 10, 10], [10, 10, 10, 10]],
[flags4, flags4],
[flags4, flags4],
),
),
[3.0, 0.0],
);
// AD Volume Line: cumulative net advancing volume.
const adv = new wickra.AdVolumeLine();
assert.equal(adv.update([1, -1], [150, 50], [false, false], [false, false]), 100.0);
assert.equal(adv.update([1, -1], [60, 60], [false, false], [false, false]), 100.0);
// McClellan Oscillator + Summation: seed 0, then -50.
const osc = new wickra.McClellanOscillator();
assert.ok(Math.abs(osc.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4)) < 1e-9);
assert.ok(Math.abs(osc.update([-1, -1, -1, 1], [10, 10, 10, 10], flags4, flags4) - -50.0) < 1e-9);
const msi = new wickra.McClellanSummationIndex();
assert.ok(Math.abs(msi.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4)) < 1e-9);
assert.ok(Math.abs(msi.update([-1, -1, -1, 1], [10, 10, 10, 10], flags4, flags4) - -50.0) < 1e-9);
// TRIN: balanced breadth -> 1.
assert.ok(
Math.abs(new wickra.Trin().update([1, 1, 1, -1], [50, 50, 50, 50], flags4, flags4) - 1.0) < 1e-9,
);
// Breadth Thrust(2): warmup null, then SMA(2) of [0.8, 0.6] = 0.7.
const bt = new wickra.BreadthThrust(2);
const up10 = Array(10).fill(false);
assert.equal(bt.update([...Array(8).fill(1), -1, -1], Array(10).fill(10), up10, up10), null);
assert.ok(
Math.abs(bt.update([...Array(6).fill(1), -1, -1, -1, -1], Array(10).fill(10), up10, up10) - 0.7) < 1e-9,
);
// New Highs - New Lows: 2 - 1 = 1.
assert.equal(
new wickra.NewHighsNewLows().update([1, 1, -1], [10, 10, 10], [true, true, false], [false, false, true]),
1.0,
);
// High-Low Index(2): warmup null, then SMA(2) of [80, 60] = 70.
const hli = new wickra.HighLowIndex(2);
assert.equal(
hli.update(Array(10).fill(1), Array(10).fill(10), [...Array(8).fill(true), false, false], [...Array(8).fill(false), true, true]),
null,
);
assert.ok(
Math.abs(
hli.update(Array(10).fill(1), Array(10).fill(10), [...Array(6).fill(true), false, false, false, false], [...Array(6).fill(false), true, true, true, true]) - 70.0,
) < 1e-9,
);
// Percent Above MA: 3/4 -> 75 (5-array update with aboveMa).
assert.equal(
new wickra.PercentAboveMa().update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4, [true, true, true, false]),
75.0,
);
// Up/Down Volume Ratio: 150/50 = 3.
assert.equal(
new wickra.UpDownVolumeRatio().update([1, -1], [150, 50], [false, false], [false, false]),
3.0,
);
// Bullish Percent Index: 2/4 -> 50 (5-array update with onBuySignal).
assert.equal(
new wickra.BullishPercentIndex().update([1, 1, -1, -1], [10, 10, 10, 10], flags4, flags4, [true, true, false, false]),
50.0,
);
// Cumulative Volume Index: (100/200) -> 0.5.
assert.ok(
Math.abs(new wickra.CumulativeVolumeIndex().update([1, -1], [150, 50], [false, false], [false, false]) - 0.5) < 1e-9,
);
// Absolute Breadth Index: |2 - 3| = 1.
assert.equal(
new wickra.AbsoluteBreadthIndex().update([1, 1, -1, -1, -1], Array(5).fill(10), Array(5).fill(false), Array(5).fill(false)),
1.0,
);
// TICK Index: 2 - 3 = -1.
assert.equal(
new wickra.TickIndex().update([1, 1, -1, -1, -1], Array(5).fill(10), Array(5).fill(false), Array(5).fill(false)),
-1.0,
);
});
test('market breadth: rejects ragged universe', () => {
assert.throws(() => new wickra.Trin().update([1, -1], [10], [false, false], [false, false]));
assert.throws(() =>
new wickra.PercentAboveMa().update([1, -1], [10, 10], [false, false], [false, false], [true]),
);
});
test('OI / flow / liquidation indicators reference values', () => {
// OI +10% while price flat -> divergence +0.1.
const div = new wickra.OIPriceDivergence(1);
+486
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
@@ -45,6 +65,10 @@ export interface BollingerValue {
lower: number
stddev: number
}
export interface HtPhasorValue {
inphase: number
quadrature: number
}
export interface StochValue {
k: number
d: number
@@ -658,6 +682,60 @@ export declare class MedianAbsoluteDeviation {
isReady(): boolean
warmupPeriod(): number
}
export type MidPointNode = MIDPOINT
export declare class MIDPOINT {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RocpNode = ROCP
export declare class ROCP {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RocrNode = ROCR
export declare class ROCR {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type Rocr100Node = ROCR100
export declare class ROCR100 {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type LinRegInterceptNode = LINEARREG_INTERCEPT
export declare class LINEARREG_INTERCEPT {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TsfNode = TSF
export declare class TSF {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AutocorrelationNode = Autocorrelation
export declare class Autocorrelation {
constructor(period: number, lag: number)
@@ -728,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)`
@@ -787,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)
@@ -801,6 +1019,36 @@ export declare class MACD {
isReady(): boolean
warmupPeriod(): number
}
export type MacdFixNode = MACDFIX
export declare class MACDFIX {
constructor(signal: number)
update(value: number): MacdValue | null
/**
* Batch over a price array. Returns a flat array of length `3 * n`,
* interleaved per row as `[macd0, signal0, histogram0, macd1, ...]`.
*/
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MacdExtNode = MACDEXT
export declare class MACDEXT {
/**
* Moving-average types are TA-Lib `MA_Type` codes `0..=5`
* (SMA, EMA, WMA, DEMA, TEMA, TRIMA).
*/
constructor(fast: number, fastMatype: number, slow: number, slowMatype: number, signal: number, signalMatype: number)
update(value: number): MacdValue | null
/**
* Batch over a price array. Returns a flat array of length `3 * n`,
* interleaved per row as `[macd0, signal0, histogram0, macd1, ...]`.
*/
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BollingerNode = BollingerBands
export declare class BollingerBands {
constructor(period: number, multiplier: number)
@@ -824,6 +1072,91 @@ export declare class ATR {
isReady(): boolean
warmupPeriod(): number
}
export type PlusDmNode = PLUS_DM
export declare class PLUS_DM {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MinusDmNode = MINUS_DM
export declare class MINUS_DM {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type PlusDiNode = PLUS_DI
export declare class PLUS_DI {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MinusDiNode = MINUS_DI
export declare class MINUS_DI {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type DxNode = DX
export declare class DX {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MidPriceNode = MIDPRICE
export declare class MIDPRICE {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AvgPriceNode = AVGPRICE
export declare class AVGPRICE {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SarExtNode = SAREXT
export declare class SAREXT {
constructor(startValue: number, offsetOnReverse: number, accelInitLong: number, accelLong: number, accelMaxLong: number, accelInitShort: number, accelShort: number, accelMaxShort: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type HtPhasorNode = HT_PHASOR
export declare class HT_PHASOR {
constructor()
update(value: number): HtPhasorValue | null
/**
* Batch over a price array. Returns a flat array of length `2 * n`,
* interleaved per row as `[inphase0, quadrature0, inphase1, ...]`.
*/
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type StochNode = Stochastic
export declare class Stochastic {
constructor(kPeriod: number, dPeriod: number)
@@ -2010,6 +2343,24 @@ export declare class EmpiricalModeDecomposition {
isReady(): boolean
warmupPeriod(): number
}
export type HtDcPhaseNode = HT_DCPHASE
export declare class HT_DCPHASE {
constructor()
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type HtTrendModeNode = HT_TRENDMODE
export declare class HT_TRENDMODE {
constructor()
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type HilbertDominantCycleNode = HilbertDominantCycle
export declare class HilbertDominantCycle {
constructor()
@@ -2893,6 +3244,141 @@ 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 AdvanceDeclineRatioNode = AdvanceDeclineRatio
export declare class AdvanceDeclineRatio {
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 AdVolumeLineNode = AdVolumeLine
export declare class AdVolumeLine {
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 McClellanOscillatorNode = McClellanOscillator
export declare class McClellanOscillator {
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 McClellanSummationIndexNode = McClellanSummationIndex
export declare class McClellanSummationIndex {
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 TrinNode = Trin
export declare class Trin {
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 BreadthThrustNode = BreadthThrust
export declare class BreadthThrust {
constructor(period: number)
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 NewHighsNewLowsNode = NewHighsNewLows
export declare class NewHighsNewLows {
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 HighLowIndexNode = HighLowIndex
export declare class HighLowIndex {
constructor(period: number)
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 PercentAboveMaNode = PercentAboveMa
export declare class PercentAboveMa {
constructor()
update(change: Array<number>, volume: Array<number>, newHigh: Array<boolean>, newLow: Array<boolean>, aboveMa: Array<boolean>): number | null
batch(change: Array<Array<number>>, volume: Array<Array<number>>, newHigh: Array<Array<boolean>>, newLow: Array<Array<boolean>>, aboveMa: Array<Array<boolean>>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type UpDownVolumeRatioNode = UpDownVolumeRatio
export declare class UpDownVolumeRatio {
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 BullishPercentIndexNode = BullishPercentIndex
export declare class BullishPercentIndex {
constructor()
update(change: Array<number>, volume: Array<number>, newHigh: Array<boolean>, newLow: Array<boolean>, onBuySignal: Array<boolean>): number | null
batch(change: Array<Array<number>>, volume: Array<Array<number>>, newHigh: Array<Array<boolean>>, newLow: Array<Array<boolean>>, onBuySignal: Array<Array<boolean>>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type CumulativeVolumeIndexNode = CumulativeVolumeIndex
export declare class CumulativeVolumeIndex {
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 AbsoluteBreadthIndexNode = AbsoluteBreadthIndex
export declare class AbsoluteBreadthIndex {
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 TickIndexNode = TickIndex
export declare class TickIndex {
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)
+45 -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, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, 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, 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, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, 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
@@ -350,19 +350,46 @@ module.exports.StandardError = StandardError
module.exports.DetrendedStdDev = DetrendedStdDev
module.exports.RSquared = RSquared
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
module.exports.MIDPOINT = MIDPOINT
module.exports.ROCP = ROCP
module.exports.ROCR = ROCR
module.exports.ROCR100 = ROCR100
module.exports.LINEARREG_INTERCEPT = LINEARREG_INTERCEPT
module.exports.TSF = TSF
module.exports.Autocorrelation = Autocorrelation
module.exports.HurstExponent = HurstExponent
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
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
module.exports.PLUS_DM = PLUS_DM
module.exports.MINUS_DM = MINUS_DM
module.exports.PLUS_DI = PLUS_DI
module.exports.MINUS_DI = MINUS_DI
module.exports.DX = DX
module.exports.MIDPRICE = MIDPRICE
module.exports.AVGPRICE = AVGPRICE
module.exports.SAREXT = SAREXT
module.exports.HT_PHASOR = HT_PHASOR
module.exports.Stochastic = Stochastic
module.exports.OBV = OBV
module.exports.ADX = ADX
@@ -491,6 +518,8 @@ module.exports.InverseFisherTransform = InverseFisherTransform
module.exports.DecyclerOscillator = DecyclerOscillator
module.exports.RoofingFilter = RoofingFilter
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
module.exports.HT_DCPHASE = HT_DCPHASE
module.exports.HT_TRENDMODE = HT_TRENDMODE
module.exports.HilbertDominantCycle = HilbertDominantCycle
module.exports.AdaptiveCycle = AdaptiveCycle
module.exports.SineWave = SineWave
@@ -588,6 +617,21 @@ module.exports.TakerBuySellRatio = TakerBuySellRatio
module.exports.LiquidationFeatures = LiquidationFeatures
module.exports.TermStructureBasis = TermStructureBasis
module.exports.CalendarSpread = CalendarSpread
module.exports.AdvanceDecline = AdvanceDecline
module.exports.AdvanceDeclineRatio = AdvanceDeclineRatio
module.exports.AdVolumeLine = AdVolumeLine
module.exports.McClellanOscillator = McClellanOscillator
module.exports.McClellanSummationIndex = McClellanSummationIndex
module.exports.Trin = Trin
module.exports.BreadthThrust = BreadthThrust
module.exports.NewHighsNewLows = NewHighsNewLows
module.exports.HighLowIndex = HighLowIndex
module.exports.PercentAboveMa = PercentAboveMa
module.exports.UpDownVolumeRatio = UpDownVolumeRatio
module.exports.BullishPercentIndex = BullishPercentIndex
module.exports.CumulativeVolumeIndex = CumulativeVolumeIndex
module.exports.AbsoluteBreadthIndex = AbsoluteBreadthIndex
module.exports.TickIndex = TickIndex
module.exports.SharpeRatio = SharpeRatio
module.exports.SortinoRatio = SortinoRatio
module.exports.CalmarRatio = CalmarRatio
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-arm64",
"version": "0.4.5",
"version": "0.5.0",
"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": [
"wickra.darwin-arm64.node"
],
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-darwin-x64",
"version": "0.4.5",
"version": "0.5.0",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
"wickra.darwin-x64.node"
],
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
@@ -1,12 +1,12 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.4.5",
"version": "0.5.0",
"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": [
"wickra.linux-arm64-gnu.node"
],
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.4.5",
"version": "0.5.0",
"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": [
"wickra.linux-x64-gnu.node"
],
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
@@ -1,12 +1,12 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.4.5",
"version": "0.5.0",
"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": [
"wickra.win32-arm64-msvc.node"
],
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
@@ -1,12 +1,12 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.4.5",
"version": "0.5.0",
"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": [
"wickra.win32-x64-msvc.node"
],
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"engines": {
"node": ">= 18"
},
+27 -27
View File
@@ -1,13 +1,13 @@
{
"name": "wickra",
"version": "0.4.5",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.4.5",
"license": "PolyForm-Noncommercial-1.0.0",
"version": "0.5.0",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
},
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.4.5",
"wickra-darwin-x64": "0.4.5",
"wickra-linux-arm64-gnu": "0.4.5",
"wickra-linux-x64-gnu": "0.4.5",
"wickra-win32-arm64-msvc": "0.4.5",
"wickra-win32-x64-msvc": "0.4.5"
"wickra-darwin-arm64": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-linux-x64-gnu": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,13 +41,13 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.5.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.0.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
@@ -57,13 +57,13 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.5.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.0.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
@@ -73,13 +73,13 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.5.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.0.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
@@ -89,13 +89,13 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.5.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.0.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
@@ -105,13 +105,13 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.5.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.0.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
@@ -121,13 +121,13 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.5.tgz",
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.0.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
],
"license": "PolyForm-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
+8 -8
View File
@@ -1,11 +1,11 @@
{
"name": "wickra",
"version": "0.4.5",
"version": "0.5.0",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
"types": "index.d.ts",
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
"license": "MIT OR Apache-2.0",
"keywords": [
"trading",
"indicators",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.4.5",
"wickra-linux-arm64-gnu": "0.4.5",
"wickra-darwin-x64": "0.4.5",
"wickra-darwin-arm64": "0.4.5",
"wickra-win32-x64-msvc": "0.4.5",
"wickra-win32-arm64-msvc": "0.4.5"
"wickra-linux-x64-gnu": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-darwin-arm64": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0"
},
"scripts": {
"build": "napi build --platform --release",
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -5,7 +5,7 @@ version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+3 -5
View File
@@ -3,7 +3,7 @@
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![PyPI](https://img.shields.io/pypi/v/wickra.svg?logo=pypi&color=blue)](https://pypi.org/project/wickra/)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](https://github.com/wickra-lib/wickra#license)
**Streaming-first technical indicators for Python. `pip install wickra` — no
system dependencies, no C build tooling.**
@@ -66,7 +66,5 @@ risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
+2 -3
View File
@@ -4,17 +4,16 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.4.5"
version = "0.5.0"
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" }
license = "MIT OR Apache-2.0"
authors = [{ name = "kingchenc", email = "support@wickra.org" }]
requires-python = ">=3.9"
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Financial and Insurance Industry",
"License :: Free for non-commercial use",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.9",
+90
View File
@@ -25,6 +25,17 @@ from __future__ import annotations
from ._wickra import (
__version__,
TSF,
LINEARREG_INTERCEPT,
ROCR100,
ROCR,
ROCP,
AVGPRICE,
MIDPOINT,
MIDPRICE,
DX,
MINUS_DI,
PLUS_DI,
# Trend
SMA,
EMA,
@@ -49,12 +60,16 @@ from ._wickra import (
RSI,
AnchoredRSI,
MACD,
MACDFIX,
MACDEXT,
Stochastic,
CCI,
ROC,
WilliamsR,
ADX,
ADXR,
PLUS_DM,
MINUS_DM,
MFI,
TRIX,
AwesomeOscillator,
@@ -98,6 +113,7 @@ from ._wickra import (
Keltner,
Donchian,
PSAR,
SAREXT,
NATR,
StdDev,
UlcerIndex,
@@ -143,6 +159,16 @@ from ._wickra import (
MarketFacilitationIndex,
EaseOfMovement,
# Statistics
SpreadBollingerBands,
KalmanHedgeRatio,
GrangerCausality,
VarianceRatio,
BetaNeutralSpread,
DistanceSsd,
SpreadHurst,
OuHalfLife,
RollingCovariance,
RollingCorrelation,
TypicalPrice,
MedianPrice,
WeightedClose,
@@ -181,6 +207,9 @@ from ._wickra import (
EhlersStochastic,
EmpiricalModeDecomposition,
HilbertDominantCycle,
HT_DCPHASE,
HT_PHASOR,
HT_TRENDMODE,
AdaptiveCycle,
SineWave,
MAMA,
@@ -322,6 +351,22 @@ from ._wickra import (
LiquidationFeatures,
TermStructureBasis,
CalendarSpread,
# Market Breadth
TickIndex,
AbsoluteBreadthIndex,
CumulativeVolumeIndex,
BullishPercentIndex,
UpDownVolumeRatio,
PercentAboveMa,
HighLowIndex,
NewHighsNewLows,
BreadthThrust,
Trin,
McClellanSummationIndex,
McClellanOscillator,
AdVolumeLine,
AdvanceDeclineRatio,
AdvanceDecline,
# Risk / Performance
SharpeRatio,
SortinoRatio,
@@ -343,6 +388,17 @@ from ._wickra import (
)
__all__ = [
"TSF",
"LINEARREG_INTERCEPT",
"ROCR100",
"ROCR",
"ROCP",
"AVGPRICE",
"MIDPOINT",
"MIDPRICE",
"DX",
"MINUS_DI",
"PLUS_DI",
"__version__",
# Trend
"SMA",
@@ -368,12 +424,16 @@ __all__ = [
"RSI",
"AnchoredRSI",
"MACD",
"MACDFIX",
"MACDEXT",
"Stochastic",
"CCI",
"ROC",
"WilliamsR",
"ADX",
"ADXR",
"PLUS_DM",
"MINUS_DM",
"MFI",
"TRIX",
"AwesomeOscillator",
@@ -417,6 +477,7 @@ __all__ = [
"Keltner",
"Donchian",
"PSAR",
"SAREXT",
"NATR",
"StdDev",
"UlcerIndex",
@@ -462,6 +523,16 @@ __all__ = [
"MarketFacilitationIndex",
"EaseOfMovement",
# Statistics
"SpreadBollingerBands",
"KalmanHedgeRatio",
"GrangerCausality",
"VarianceRatio",
"BetaNeutralSpread",
"DistanceSsd",
"SpreadHurst",
"OuHalfLife",
"RollingCovariance",
"RollingCorrelation",
"TypicalPrice",
"MedianPrice",
"WeightedClose",
@@ -500,6 +571,9 @@ __all__ = [
"EhlersStochastic",
"EmpiricalModeDecomposition",
"HilbertDominantCycle",
"HT_DCPHASE",
"HT_PHASOR",
"HT_TRENDMODE",
"AdaptiveCycle",
"SineWave",
"MAMA",
@@ -641,6 +715,22 @@ __all__ = [
"LiquidationFeatures",
"TermStructureBasis",
"CalendarSpread",
# Market Breadth
"TickIndex",
"AbsoluteBreadthIndex",
"CumulativeVolumeIndex",
"BullishPercentIndex",
"UpDownVolumeRatio",
"PercentAboveMa",
"HighLowIndex",
"NewHighsNewLows",
"BreadthThrust",
"Trin",
"McClellanSummationIndex",
"McClellanOscillator",
"AdVolumeLine",
"AdvanceDeclineRatio",
"AdvanceDecline",
# Risk / Performance
"SharpeRatio",
"SortinoRatio",
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,12 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.TSF, (14,)),
(ta.LINEARREG_INTERCEPT, (14,)),
(ta.ROCR100, (10,)),
(ta.ROCR, (10,)),
(ta.ROCP, (10,)),
(ta.MIDPOINT, (14,)),
(ta.SMMA, (14,)),
(ta.TRIMA, (20,)),
(ta.ZLEMA, (14,)),
@@ -96,6 +102,8 @@ SCALAR = [
(ta.EhlersStochastic, (20,)),
(ta.EmpiricalModeDecomposition, (20, 0.5)),
(ta.HilbertDominantCycle, ()),
(ta.HT_DCPHASE, ()),
(ta.HT_TRENDMODE, ()),
(ta.AdaptiveCycle, ()),
(ta.SineWave, ()),
(ta.FAMA, (0.5, 0.05)),
@@ -136,6 +144,9 @@ SCALAR_MULTI = {
"LinRegChannel": (lambda: ta.LinRegChannel(20, 2.0), 3),
"StandardErrorBands": (lambda: ta.StandardErrorBands(21, 2.0), 3),
"DoubleBollinger": (lambda: ta.DoubleBollinger(20, 1.0, 2.0), 5),
"MacdFix": (lambda: ta.MACDFIX(9), 3),
"MacdExt": (lambda: ta.MACDEXT(12, 0, 26, 0, 9, 0), 3),
"HtPhasor": (lambda: ta.HT_PHASOR(), 2),
}
@@ -156,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)),
@@ -240,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)
@@ -275,7 +330,15 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"MIDPRICE": (lambda: ta.MIDPRICE(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"AVGPRICE": (lambda: ta.AVGPRICE(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"DX": (lambda: ta.DX(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"MINUS_DI": (lambda: ta.MINUS_DI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"PLUS_DI": (lambda: ta.PLUS_DI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"VWMA": (lambda: ta.VWMA(20), lambda ind, h, l, c, v: ind.batch(c, v)),
"SAREXT": (lambda: ta.SAREXT(), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"PLUS_DM": (lambda: ta.PLUS_DM(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"MINUS_DM": (lambda: ta.MINUS_DM(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"RVI": (
# extract_candle pulls the open price from index 0 of the tuple; the
# streaming test below already builds candles with open == close, so
@@ -1171,6 +1234,99 @@ def test_weighted_close_reference():
)
def test_plus_dm_reference():
# Highs rise by 1 (up = +1) while lows rise by 0.5, so every raw +DM equals
# the up-move (1.0). Period 3: seed = 3 * 1 = 3.0, then the Wilder step holds it.
high = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
low = np.array([9.0, 9.5, 10.0, 10.5, 11.0])
close = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
out = ta.PLUS_DM(3).batch(high, low, close)
assert math.isnan(out[0]) and math.isnan(out[2])
assert out[3] == pytest.approx(3.0)
assert out[4] == pytest.approx(3.0)
def test_minus_dm_reference():
# Lows fall by 1 (down = +1) while highs fall by 0.5, so every raw -DM equals
# the down-move (1.0). Period 3: seed = 3 * 1 = 3.0, then the Wilder step holds it.
high = np.array([20.0, 19.5, 19.0, 18.5, 18.0])
low = np.array([18.0, 17.0, 16.0, 15.0, 14.0])
close = np.array([19.0, 18.0, 17.0, 16.0, 15.0])
out = ta.MINUS_DM(3).batch(high, low, close)
assert math.isnan(out[0]) and math.isnan(out[2])
assert out[3] == pytest.approx(3.0)
assert out[4] == pytest.approx(3.0)
def test_plus_di_reference():
# Strict uptrend -> +DI dominates and stays within (0, 100].
high = np.array([101.0, 103.0, 105.0, 107.0, 109.0, 111.0])
low = np.array([99.5, 101.5, 103.5, 105.5, 107.5, 109.5])
close = np.array([100.5, 102.5, 104.5, 106.5, 108.5, 110.5])
out = ta.PLUS_DI(3).batch(high, low, close)
assert 0.0 < out[-1] <= 100.0
def test_minus_di_reference():
# Strict downtrend -> -DI dominates and stays within (0, 100].
high = np.array([111.0, 109.0, 107.0, 105.0, 103.0, 101.0])
low = np.array([109.5, 107.5, 105.5, 103.5, 101.5, 99.5])
close = np.array([110.5, 108.5, 106.5, 104.5, 102.5, 100.5])
out = ta.MINUS_DI(3).batch(high, low, close)
assert 0.0 < out[-1] <= 100.0
def test_dx_reference():
# Strict trend -> one-sided directional movement -> DX is large, in (0, 100].
high = np.array([101.0, 103.0, 105.0, 107.0, 109.0, 111.0])
low = np.array([99.5, 101.5, 103.5, 105.5, 107.5, 109.5])
close = np.array([100.5, 102.5, 104.5, 106.5, 108.5, 110.5])
out = ta.DX(3).batch(high, low, close)
assert 50.0 < out[-1] <= 100.0
def test_mid_price_reference():
# Window highs {12, 14, 16}, lows {8, 9, 10}: (16 + 8) / 2 = 12.
high = np.array([12.0, 14.0, 16.0])
low = np.array([8.0, 9.0, 10.0])
close = np.array([10.0, 11.0, 12.0])
out = ta.MIDPRICE(3).batch(high, low, close)
assert out[-1] == pytest.approx(12.0)
def test_mid_point_reference():
# Window {8, 12, 10}: (12 + 8) / 2 = 10.
out = ta.MIDPOINT(3).batch(np.array([8.0, 12.0, 10.0]))
assert out[-1] == pytest.approx(10.0)
def test_avg_price_reference():
# (open + high + low + close) / 4 = (10 + 14 + 6 + 12) / 4 = 10.5.
assert ta.AVGPRICE().update((10.0, 14.0, 6.0, 12.0, 1.0, 0)) == pytest.approx(10.5)
def test_roc_ratio_variants_reference():
# period 1 over [10, 11]: ROCP = 0.1, ROCR = 1.1, ROCR100 = 110.
assert ta.ROCP(1).batch(np.array([10.0, 11.0]))[-1] == pytest.approx(0.1)
assert ta.ROCR(1).batch(np.array([10.0, 11.0]))[-1] == pytest.approx(1.1)
assert ta.ROCR100(1).batch(np.array([10.0, 11.0]))[-1] == pytest.approx(110.0)
def test_linreg_intercept_and_tsf_reference():
# period 3 over [1, 2, 9]: fit y = 0 + 4x. intercept = 0; forecast at x=3 = 12.
data = np.array([1.0, 2.0, 9.0])
assert ta.LINEARREG_INTERCEPT(3).batch(data)[-1] == pytest.approx(0.0, abs=1e-9)
assert ta.TSF(3).batch(data)[-1] == pytest.approx(12.0)
def test_macdfix_matches_macd():
# MACDFIX(signal) is exactly MACD(12, 26, signal).
prices = 100.0 + np.sin(np.arange(80) * 0.3) * 5.0
fix = ta.MACDFIX(9).batch(prices)
classic = ta.MACD(12, 26, 9).batch(prices)
np.testing.assert_allclose(fix, classic, equal_nan=True)
def test_nvi_reference():
# closes [10, 11], volumes [200, 100]: volume contracts -> NVI absorbs +10%.
# 1000 * (1 + 0.1) = 1100.
@@ -2150,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 ------------------------------------------------------------
@@ -2547,6 +2751,222 @@ 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 _breadth_streaming_equals_batch(indicator, change, volume, new_high, new_low):
"""Assert a 4-array breadth indicator's batch matches its streaming output."""
batch = indicator().batch(change, volume, new_high, new_low)
streamer = indicator()
streamed = np.array(
[
streamer.update(change[i], volume[i], new_high[i], new_low[i])
for i in range(len(change))
],
dtype=np.float64,
)
assert batch.shape == (len(change),)
assert _eq_nan(batch, streamed)
return batch
def test_advance_decline_ratio_breadth():
change = [[1.0, 1.0, 1.0, -1.0], [1.0, 0.0, 0.0, 0.0], [-1.0, -1.0, -1.0, -1.0]]
volume = [[10.0] * 4 for _ in range(3)]
flags = [[False] * 4 for _ in range(3)]
batch = _breadth_streaming_equals_batch(ta.AdvanceDeclineRatio, change, volume, flags, flags)
# 3/1 = 3 ; 1/max(0,1) = 1 ; 0/3 = 0.
assert list(batch) == [3.0, 1.0, 0.0]
def test_ad_volume_line_breadth():
change = [[1.0, -1.0], [1.0, -1.0], [1.0, 0.0]]
volume = [[150.0, 50.0], [60.0, 60.0], [30.0, 0.0]]
flags = [[False] * 2 for _ in range(3)]
batch = _breadth_streaming_equals_batch(ta.AdVolumeLine, change, volume, flags, flags)
# net +100 -> 100 ; net 0 -> 100 ; net +30 -> 130.
assert list(batch) == [100.0, 100.0, 130.0]
def test_mcclellan_oscillator_breadth():
change = [[1.0, 1.0, 1.0, -1.0], [-1.0, -1.0, -1.0, 1.0], [1.0, 1.0, -1.0, -1.0]]
volume = [[10.0] * 4 for _ in range(3)]
flags = [[False] * 4 for _ in range(3)]
batch = _breadth_streaming_equals_batch(ta.McClellanOscillator, change, volume, flags, flags)
# seed 0 ; -50 ; -67.5.
assert abs(batch[0]) < 1e-9
assert abs(batch[1] - (-50.0)) < 1e-9
assert abs(batch[2] - (-67.5)) < 1e-9
def test_mcclellan_summation_index_breadth():
change = [[1.0, 1.0, 1.0, -1.0], [-1.0, -1.0, -1.0, 1.0], [1.0, 1.0, -1.0, -1.0]]
volume = [[10.0] * 4 for _ in range(3)]
flags = [[False] * 4 for _ in range(3)]
batch = _breadth_streaming_equals_batch(ta.McClellanSummationIndex, change, volume, flags, flags)
# 0 ; -50 ; -117.5.
assert abs(batch[0]) < 1e-9
assert abs(batch[1] - (-50.0)) < 1e-9
assert abs(batch[2] - (-117.5)) < 1e-9
def test_trin_breadth():
change = [[1.0, 1.0, 1.0, -1.0], [1.0, 1.0, -1.0, -1.0]]
volume = [[50.0, 50.0, 50.0, 50.0], [10.0, 10.0, 40.0, 40.0]]
flags = [[False] * 4 for _ in range(2)]
batch = _breadth_streaming_equals_batch(ta.Trin, change, volume, flags, flags)
# (3/1)/(150/50) = 1 ; (2/2)/(20/80) = 4.
assert abs(batch[0] - 1.0) < 1e-9
assert abs(batch[1] - 4.0) < 1e-9
def test_breadth_thrust_breadth():
change = [[1.0] * 8 + [-1.0] * 2, [1.0] * 6 + [-1.0] * 4]
volume = [[10.0] * 10 for _ in range(2)]
flags = [[False] * 10 for _ in range(2)]
batch = ta.BreadthThrust(2).batch(change, volume, flags, flags)
streamer = ta.BreadthThrust(2)
streamed = np.array(
[streamer.update(change[i], volume[i], flags[i], flags[i]) for i in range(2)],
dtype=np.float64,
)
assert _eq_nan(batch, streamed)
# 0.8 (warmup -> NaN) ; SMA(2) of [0.8, 0.6] = 0.7.
assert math.isnan(batch[0])
assert abs(batch[1] - 0.7) < 1e-9
def test_new_highs_new_lows_breadth():
change = [[1.0, 1.0, -1.0], [1.0, -1.0, -1.0]]
volume = [[10.0] * 3 for _ in range(2)]
new_high = [[True, True, False], [True, False, False]]
new_low = [[False, False, True], [False, True, True]]
batch = _breadth_streaming_equals_batch(ta.NewHighsNewLows, change, volume, new_high, new_low)
# 2 - 1 = 1 ; 1 - 2 = -1.
assert list(batch) == [1.0, -1.0]
def test_high_low_index_breadth():
change = [[1.0] * 10, [1.0] * 10]
volume = [[10.0] * 10 for _ in range(2)]
new_high = [[True] * 8 + [False] * 2, [True] * 6 + [False] * 4]
new_low = [[False] * 8 + [True] * 2, [False] * 6 + [True] * 4]
batch = ta.HighLowIndex(2).batch(change, volume, new_high, new_low)
streamer = ta.HighLowIndex(2)
streamed = np.array(
[streamer.update(change[i], volume[i], new_high[i], new_low[i]) for i in range(2)],
dtype=np.float64,
)
assert _eq_nan(batch, streamed)
# 80% (warmup) ; SMA(2) of [80, 60] = 70.
assert math.isnan(batch[0])
assert abs(batch[1] - 70.0) < 1e-9
def test_percent_above_ma_breadth():
change = [[1.0, 1.0, 1.0, -1.0], [1.0, 1.0, -1.0, -1.0]]
volume = [[10.0] * 4 for _ in range(2)]
flags = [[False] * 4 for _ in range(2)]
above_ma = [[True, True, True, False], [True, False, False, False]]
batch = ta.PercentAboveMa().batch(change, volume, flags, flags, above_ma)
streamer = ta.PercentAboveMa()
streamed = np.array(
[streamer.update(change[i], volume[i], flags[i], flags[i], above_ma[i]) for i in range(2)],
dtype=np.float64,
)
assert _eq_nan(batch, streamed)
# 3/4 -> 75 ; 1/4 -> 25.
assert list(batch) == [75.0, 25.0]
def test_up_down_volume_ratio_breadth():
change = [[1.0, -1.0], [1.0, 0.0]]
volume = [[150.0, 50.0], [100.0, 0.0]]
flags = [[False] * 2 for _ in range(2)]
batch = _breadth_streaming_equals_batch(ta.UpDownVolumeRatio, change, volume, flags, flags)
# 150/50 = 3 ; 100/max(0,1) = 100.
assert list(batch) == [3.0, 100.0]
def test_bullish_percent_index_breadth():
change = [[1.0, 1.0, -1.0, -1.0], [1.0, 1.0, 1.0, 1.0]]
volume = [[10.0] * 4 for _ in range(2)]
flags = [[False] * 4 for _ in range(2)]
on_buy = [[True, True, False, False], [True, True, True, True]]
batch = ta.BullishPercentIndex().batch(change, volume, flags, flags, on_buy)
streamer = ta.BullishPercentIndex()
streamed = np.array(
[streamer.update(change[i], volume[i], flags[i], flags[i], on_buy[i]) for i in range(2)],
dtype=np.float64,
)
assert _eq_nan(batch, streamed)
# 2/4 -> 50 ; 4/4 -> 100.
assert list(batch) == [50.0, 100.0]
def test_cumulative_volume_index_breadth():
change = [[1.0, -1.0], [1.0, -1.0], [0.0]]
volume = [[150.0, 50.0], [60.0, 60.0], [0.0]]
new_high = [[False, False], [False, False], [False]]
new_low = [[False, False], [False, False], [False]]
batch = ta.CumulativeVolumeIndex().batch(change, volume, new_high, new_low)
streamer = ta.CumulativeVolumeIndex()
streamed = np.array(
[streamer.update(change[i], volume[i], new_high[i], new_low[i]) for i in range(3)],
dtype=np.float64,
)
assert _eq_nan(batch, streamed)
# (100/200) -> 0.5 ; net 0 -> 0.5 ; zero-volume tick -> 0.5.
assert list(batch) == [0.5, 0.5, 0.5]
def test_absolute_breadth_index_breadth():
change = [[1.0, 1.0, -1.0, -1.0, -1.0], [1.0, 1.0, 1.0, -1.0, -1.0]]
volume = [[10.0] * 5 for _ in range(2)]
flags = [[False] * 5 for _ in range(2)]
batch = _breadth_streaming_equals_batch(ta.AbsoluteBreadthIndex, change, volume, flags, flags)
# |2 - 3| = 1 ; |3 - 2| = 1.
assert list(batch) == [1.0, 1.0]
def test_tick_index_breadth():
change = [[1.0, 1.0, -1.0, -1.0, -1.0], [1.0, 1.0, 1.0, -1.0, -1.0]]
volume = [[10.0] * 5 for _ in range(2)]
flags = [[False] * 5 for _ in range(2)]
batch = _breadth_streaming_equals_batch(ta.TickIndex, change, volume, flags, flags)
# 2 - 3 = -1 ; 3 - 2 = 1.
assert list(batch) == [-1.0, 1.0]
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)
+1 -1
View File
@@ -5,7 +5,7 @@ version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+3 -5
View File
@@ -3,7 +3,7 @@
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/wickra-lib/wickra/branch/main/graph/badge.svg)](https://codecov.io/gh/wickra-lib/wickra)
[![npm](https://img.shields.io/npm/v/wickra-wasm.svg?logo=npm&color=red)](https://www.npmjs.com/package/wickra-wasm)
[![License: PolyForm-NC](https://img.shields.io/badge/license-PolyForm--NC--1.0.0-purple)](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT_OR_Apache--2.0-blue)](https://github.com/wickra-lib/wickra#license)
**Streaming-first technical indicators in the browser. `npm install
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
@@ -66,7 +66,5 @@ risk. The library is provided **as is**, without warranty of any kind.
## License
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
research, education, non-profits, and hobby trading bots are all fine; the one
thing not allowed is commercial sale of the software or of services built
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
# Proper nouns that appear in indicator documentation. They are real names,
# not code identifiers, so `clippy::doc_markdown` must not demand backticks.
# `..` keeps clippy's built-in default identifier list in addition to these.
doc-valid-idents = ["LeBeau", ".."]
doc-valid-idents = ["LeBeau", "McClellan", ".."]
+1 -1
View File
@@ -5,7 +5,7 @@ version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+387
View File
@@ -0,0 +1,387 @@
//! 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`, the
//! `new_high` / `new_low` extreme flags, and the `above_ma` / `on_buy_signal`
//! state 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; `above_ma` / `on_buy_signal` are caller-supplied
/// per-symbol state signals (whether the symbol trades above its reference moving
/// average, and whether it is on a point-and-figure buy signal). None of the four
/// flags carries a numeric invariant.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq)]
#[allow(
clippy::struct_excessive_bools,
reason = "the four flags are independent per-symbol breadth signals, not a state machine"
)]
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,
/// Whether the symbol is trading above its reference moving average
/// (consumed by the `% Above Moving Average` breadth indicator).
pub above_ma: bool,
/// Whether the symbol is on a point-and-figure buy signal
/// (consumed by the `Bullish Percent Index` breadth indicator).
pub on_buy_signal: bool,
}
impl Member {
/// Assemble a cross-section member from its core signals, leaving the
/// extended per-symbol state flags (`above_ma`, `on_buy_signal`) cleared.
///
/// 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,
above_ma: false,
on_buy_signal: false,
}
}
/// Assemble a cross-section member including the extended per-symbol state
/// signals `above_ma` and `on_buy_signal`.
///
/// Use this constructor for the breadth indicators that read per-symbol
/// state (`% Above Moving Average`, `Bullish Percent Index`); [`new`](Member::new)
/// is the shorthand that leaves both flags `false`.
#[must_use]
#[allow(
clippy::fn_params_excessive_bools,
reason = "mirrors the four independent per-symbol flag fields of Member"
)]
pub const fn with_signals(
change: f64,
volume: f64,
new_high: bool,
new_low: bool,
above_ma: bool,
on_buy_signal: bool,
) -> Self {
Self {
change,
volume,
new_high,
new_low,
above_ma,
on_buy_signal,
}
}
}
/// 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()
}
/// Total volume traded by advancing symbols (those with positive `change`).
#[must_use]
pub fn advancing_volume(&self) -> f64 {
self.members
.iter()
.filter(|m| m.change > 0.0)
.map(|m| m.volume)
.sum()
}
/// Total volume traded by declining symbols (those with negative `change`).
#[must_use]
pub fn declining_volume(&self) -> f64 {
self.members
.iter()
.filter(|m| m.change < 0.0)
.map(|m| m.volume)
.sum()
}
/// Total volume traded across the whole universe.
#[must_use]
pub fn total_volume(&self) -> f64 {
self.members.iter().map(|m| m.volume).sum()
}
/// Number of symbols that printed a new period high.
#[must_use]
pub fn new_highs(&self) -> usize {
self.members.iter().filter(|m| m.new_high).count()
}
/// Number of symbols that printed a new period low.
#[must_use]
pub fn new_lows(&self) -> usize {
self.members.iter().filter(|m| m.new_low).count()
}
/// Number of symbols trading above their reference moving average.
#[must_use]
pub fn above_ma_count(&self) -> usize {
self.members.iter().filter(|m| m.above_ma).count()
}
/// Number of symbols on a point-and-figure buy signal.
#[must_use]
pub fn on_buy_signal_count(&self) -> usize {
self.members.iter().filter(|m| m.on_buy_signal).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);
}
#[test]
fn new_leaves_extended_flags_cleared() {
let m = Member::new(1.0, 10.0, true, false);
assert!(!m.above_ma);
assert!(!m.on_buy_signal);
}
#[test]
fn with_signals_assembles_all_fields() {
let m = Member::with_signals(2.0, 10.0, true, false, true, true);
assert_eq!(m.change, 2.0);
assert_eq!(m.volume, 10.0);
assert!(m.new_high);
assert!(!m.new_low);
assert!(m.above_ma);
assert!(m.on_buy_signal);
}
#[test]
fn volume_helpers_bucket_by_change_sign() {
let cs = CrossSection::new(
vec![
Member::new(1.5, 100.0, false, false), // advancing
Member::new(2.0, 40.0, false, false), // advancing
Member::new(-0.5, 50.0, false, false), // declining
Member::new(0.0, 7.0, false, false), // unchanged
],
0,
)
.unwrap();
assert_eq!(cs.advancing_volume(), 140.0);
assert_eq!(cs.declining_volume(), 50.0);
assert_eq!(cs.total_volume(), 197.0);
}
#[test]
fn high_low_helpers_count_flags() {
let cs = CrossSection::new(
vec![
Member::new(1.0, 1.0, true, false),
Member::new(1.0, 1.0, true, false),
Member::new(-1.0, 1.0, false, true),
],
0,
)
.unwrap();
assert_eq!(cs.new_highs(), 2);
assert_eq!(cs.new_lows(), 1);
}
#[test]
fn state_helpers_count_extended_flags() {
let cs = CrossSection::new(
vec![
Member::with_signals(1.0, 1.0, false, false, true, true),
Member::with_signals(1.0, 1.0, false, false, true, false),
Member::with_signals(-1.0, 1.0, false, false, false, true),
],
0,
)
.unwrap();
assert_eq!(cs.above_ma_count(), 2);
assert_eq!(cs.on_buy_signal_count(), 2);
}
}
+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,144 @@
//! Absolute Breadth Index — the magnitude of net advancing-minus-declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Absolute Breadth Index (ABI) — the absolute value of net advancing issues,
/// `|advancers - decliners|`.
///
/// The ABI ignores the *direction* of breadth and measures only its *magnitude*:
/// a high reading means the universe moved decisively one way or the other (high
/// internal activity / volatility), while a low reading means advances and
/// declines were nearly balanced (a quiet, directionless market). It is sometimes
/// called a "market thermometer" because elevated readings often cluster around
/// turning points.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{AbsoluteBreadthIndex, CrossSection, Indicator, Member};
///
/// let mut abi = AbsoluteBreadthIndex::new();
/// // 2 advancers, 5 decliners -> |2 - 5| = 3.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(abi.update(tick), Some(3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AbsoluteBreadthIndex {
has_emitted: bool,
}
impl AbsoluteBreadthIndex {
/// Construct a new Absolute Breadth Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AbsoluteBreadthIndex {
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.has_emitted = true;
Some(net.abs())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AbsoluteBreadthIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: 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));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.name(), "AbsoluteBreadthIndex");
assert_eq!(abi.warmup_period(), 1);
assert!(!abi.is_ready());
}
#[test]
fn magnitude_ignores_direction() {
let mut abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.update(section(2, 5)), Some(3.0));
// Same magnitude with the direction reversed.
let mut abi2 = AbsoluteBreadthIndex::new();
assert_eq!(abi2.update(section(5, 2)), Some(3.0));
}
#[test]
fn balanced_universe_yields_zero() {
let mut abi = AbsoluteBreadthIndex::new();
assert_eq!(abi.update(section(3, 3)), Some(0.0));
assert!(abi.is_ready());
}
#[test]
fn reset_clears_state() {
let mut abi = AbsoluteBreadthIndex::new();
abi.update(section(2, 5));
assert!(abi.is_ready());
abi.reset();
assert!(!abi.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(2, 5), section(5, 2), section(3, 3)];
let mut a = AbsoluteBreadthIndex::new();
let mut b = AbsoluteBreadthIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,157 @@
//! Advance/Decline Volume Line — cumulative net advancing-minus-declining volume.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Advance/Decline Volume Line (AD Volume Line) — the running cumulative sum of
/// net advancing volume across a universe.
///
/// On each [`CrossSection`] tick the net is `advancing volume - declining volume`,
/// where advancing volume is the total volume of symbols with a positive change
/// and declining volume the total volume of symbols with a negative change. The
/// line accumulates this net over time, so a rising line means volume is flowing
/// into advancing issues (healthy participation) while a falling line warns that
/// declining issues are carrying the volume — the volume-weighted analogue of the
/// plain Advance/Decline Line.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1` (defined from the
/// first tick).
///
/// # Example
///
/// ```
/// use wickra_core::{AdVolumeLine, CrossSection, Indicator, Member};
///
/// let mut adv = AdVolumeLine::new();
/// // advancing volume 150, declining volume 50 -> net +100.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 150.0, false, false),
/// Member::new(-1.0, 50.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(adv.update(tick), Some(100.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdVolumeLine {
line: f64,
has_emitted: bool,
}
impl AdVolumeLine {
/// Construct a new Advance/Decline Volume Line indicator.
#[must_use]
pub const fn new() -> Self {
Self {
line: 0.0,
has_emitted: false,
}
}
}
impl Indicator for AdVolumeLine {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancing_volume() - section.declining_volume();
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 {
"AdVolumeLine"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(items: &[(f64, f64)]) -> CrossSection {
CrossSection::new(
items
.iter()
.map(|&(change, volume)| Member::new(change, volume, false, false))
.collect(),
0,
)
.unwrap()
}
#[test]
fn accessors_and_metadata() {
let adv = AdVolumeLine::new();
assert_eq!(adv.name(), "AdVolumeLine");
assert_eq!(adv.warmup_period(), 1);
assert!(!adv.is_ready());
}
#[test]
fn first_tick_emits_net_volume() {
let mut adv = AdVolumeLine::new();
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
assert!(adv.is_ready());
}
#[test]
fn line_accumulates_across_ticks() {
let mut adv = AdVolumeLine::new();
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
assert_eq!(adv.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(100.0));
assert_eq!(adv.update(tick(&[(1.0, 30.0)])), Some(130.0));
}
#[test]
fn unchanged_volume_is_ignored() {
let mut adv = AdVolumeLine::new();
// Unchanged symbols (zero change) contribute to neither bucket.
assert_eq!(adv.update(tick(&[(0.0, 1000.0), (1.0, 10.0)])), Some(10.0));
}
#[test]
fn reset_clears_state() {
let mut adv = AdVolumeLine::new();
adv.update(tick(&[(1.0, 100.0)]));
assert!(adv.is_ready());
adv.reset();
assert!(!adv.is_ready());
assert_eq!(adv.update(tick(&[(1.0, 20.0)])), Some(20.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
tick(&[(1.0, 150.0), (-1.0, 50.0)]),
tick(&[(1.0, 60.0), (-1.0, 60.0)]),
tick(&[(1.0, 30.0)]),
];
let mut a = AdVolumeLine::new();
let mut b = AdVolumeLine::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -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,151 @@
//! Advance/Decline Ratio — advancing issues divided by declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Advance/Decline Ratio (ADR) — the number of advancing symbols divided by the
/// number of declining symbols across a universe.
///
/// On each [`CrossSection`] tick the ratio is `advancers / decliners`: a reading
/// above one means advancing issues outnumber declining ones (broad strength),
/// while a reading below one signals broad weakness. Because it is a ratio rather
/// than a difference, the ADR is comparable across universes of different sizes.
///
/// When a tick has no declining symbols the denominator is floored to one, so the
/// ratio degrades gracefully to the advancer count instead of dividing by zero.
///
/// `Input = CrossSection`, `Output = f64`. The ratio is defined from the first
/// tick, so `warmup_period == 1` and the indicator is ready after one update.
///
/// # Example
///
/// ```
/// use wickra_core::{AdvanceDeclineRatio, CrossSection, Indicator, Member};
///
/// let mut adr = AdvanceDeclineRatio::new();
/// // 3 advancers, 1 decliner -> ratio 3.0.
/// 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!(adr.update(tick), Some(3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct AdvanceDeclineRatio {
has_emitted: bool,
}
impl AdvanceDeclineRatio {
/// Construct a new Advance/Decline Ratio indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AdvanceDeclineRatio {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let advancers = section.advancers() as f64;
let decliners = section.decliners().max(1) as f64;
self.has_emitted = true;
Some(advancers / decliners)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AdvanceDeclineRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: 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));
}
// A non-empty unchanged member guarantees a valid universe when both
// counts are zero.
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let adr = AdvanceDeclineRatio::new();
assert_eq!(adr.name(), "AdvanceDeclineRatio");
assert_eq!(adr.warmup_period(), 1);
assert!(!adr.is_ready());
}
#[test]
fn first_tick_emits_ratio() {
let mut adr = AdvanceDeclineRatio::new();
assert_eq!(adr.update(section(3, 1)), Some(3.0));
assert!(adr.is_ready());
}
#[test]
fn zero_decliners_floors_denominator() {
let mut adr = AdvanceDeclineRatio::new();
// 4 advancers, 0 decliners -> 4 / max(0, 1) = 4.0.
assert_eq!(adr.update(section(4, 0)), Some(4.0));
}
#[test]
fn no_advancers_yields_zero() {
let mut adr = AdvanceDeclineRatio::new();
assert_eq!(adr.update(section(0, 5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut adr = AdvanceDeclineRatio::new();
adr.update(section(3, 1));
assert!(adr.is_ready());
adr.reset();
assert!(!adr.is_ready());
assert_eq!(adr.update(section(2, 1)), Some(2.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(3, 1), section(4, 0), section(0, 5), section(2, 2)];
let mut a = AdvanceDeclineRatio::new();
let mut b = AdvanceDeclineRatio::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ impl Adx {
}
}
fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
pub(crate) fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
let up = current.high - prev.high;
let down = prev.low - current.low;
let plus_dm = if up > down && up > 0.0 { up } else { 0.0 };
@@ -0,0 +1,92 @@
//! Average Price (AVGPRICE).
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Average Price (`AVGPRICE`) — the bar's `(open + high + low + close) / 4`.
///
/// A per-bar price aggregate that, unlike [`TypicalPrice`](crate::TypicalPrice)
/// and [`WeightedClose`](crate::WeightedClose), folds in the open as well as the
/// high, low and close. As a stateless transform it emits a value from the very
/// first candle.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AvgPrice};
///
/// let mut indicator = AvgPrice::new();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct AvgPrice {
has_emitted: bool,
}
impl AvgPrice {
/// Construct a new Average Price transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AvgPrice {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
Some(candle.avg_price())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AVGPRICE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn averages_the_four_prices() {
// (open + high + low + close) / 4 = (10 + 14 + 6 + 12) / 4 = 10.5.
let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap();
let mut ap = AvgPrice::new();
assert!(!ap.is_ready());
assert_relative_eq!(ap.update(candle).unwrap(), 10.5, epsilon = 1e-12);
assert!(ap.is_ready());
}
#[test]
fn accessors_and_reset() {
let mut ap = AvgPrice::new();
assert_eq!(ap.name(), "AVGPRICE");
assert_eq!(ap.warmup_period(), 1);
let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap();
let _ = ap.update(candle);
assert!(ap.is_ready());
ap.reset();
assert!(!ap.is_ready());
}
}
@@ -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,165 @@
//! Breadth Thrust (Zweig) — a moving average of the advancing-issues share.
use crate::cross_section::CrossSection;
use crate::error::Result;
use crate::traits::Indicator;
use crate::Sma;
/// Breadth Thrust (Zweig) — a simple moving average of the advancing-issues
/// share, `advancers / (advancers + decliners)`.
///
/// Martin Zweig's breadth thrust smooths the fraction of participating issues
/// that are advancing over a short window (the classic period is 10). A "thrust"
/// fires when this average climbs from below ~0.40 (oversold, washed-out breadth)
/// to above ~0.615 within about ten sessions — historically a rare, reliable
/// signal that a powerful new advance has begun with broad participation.
///
/// Each tick's share floors the participating count to one, so a tick with no
/// advancing or declining issues contributes a defined `0.0` instead of dividing
/// by zero. The reading is `None` until `period` ticks have been seen.
///
/// `Input = CrossSection`, `Output = f64` (a share in `0..=1`),
/// `warmup_period == period`.
///
/// # Example
///
/// ```
/// use wickra_core::{BreadthThrust, CrossSection, Indicator, Member};
///
/// let mut bt = BreadthThrust::new(2).unwrap();
/// let up = CrossSection::new(vec![Member::new(1.0, 1.0, false, false)], 0).unwrap();
/// assert_eq!(bt.update(up.clone()), None); // warming up
/// assert_eq!(bt.update(up), Some(1.0)); // both ticks 100% advancing
/// ```
#[derive(Debug, Clone)]
pub struct BreadthThrust {
sma: Sma,
}
impl BreadthThrust {
/// Construct a new Breadth Thrust over the given window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
sma: Sma::new(period)?,
})
}
/// Configured window length.
#[must_use]
pub const fn period(&self) -> usize {
self.sma.period()
}
}
impl Indicator for BreadthThrust {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let advancers = section.advancers();
let decliners = section.decliners();
let participating = (advancers + decliners).max(1) as f64;
let share = advancers as f64 / participating;
self.sma.update(share)
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.sma.period()
}
fn is_ready(&self) -> bool {
self.sma.value().is_some()
}
fn name(&self) -> &'static str {
"BreadthThrust"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::error::Error;
use crate::traits::BatchExt;
fn section(up: usize, down: 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));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let bt = BreadthThrust::new(10).unwrap();
assert_eq!(bt.name(), "BreadthThrust");
assert_eq!(bt.warmup_period(), 10);
assert_eq!(bt.period(), 10);
assert!(!bt.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(matches!(BreadthThrust::new(0), Err(Error::PeriodZero)));
}
#[test]
fn averages_the_advancing_share() {
let mut bt = BreadthThrust::new(2).unwrap();
// share = 8 / 10 = 0.8 ; window not full yet.
assert_eq!(bt.update(section(8, 2)), None);
// share = 6 / 10 = 0.6 ; SMA(2) = (0.8 + 0.6) / 2 = 0.7.
let value = bt.update(section(6, 4)).unwrap();
assert!((value - 0.7).abs() < 1e-9);
assert!(bt.is_ready());
// share = 5 / 10 = 0.5 ; SMA(2) = (0.6 + 0.5) / 2 = 0.55.
let value = bt.update(section(5, 5)).unwrap();
assert!((value - 0.55).abs() < 1e-9);
}
#[test]
fn empty_participation_floors_to_zero_share() {
let mut bt = BreadthThrust::new(1).unwrap();
// No advancers or decliners -> 0 / max(0, 1) = 0.0.
assert_eq!(bt.update(section(0, 0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut bt = BreadthThrust::new(2).unwrap();
bt.update(section(8, 2));
bt.update(section(6, 4));
assert!(bt.is_ready());
bt.reset();
assert!(!bt.is_ready());
assert_eq!(bt.update(section(8, 2)), None);
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(8, 2), section(6, 4), section(5, 5), section(0, 0)];
let mut a = BreadthThrust::new(2).unwrap();
let mut b = BreadthThrust::new(2).unwrap();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,147 @@
//! Bullish Percent Index — share of a universe on a point-and-figure buy signal.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Bullish Percent Index (BPI) — the percentage of symbols in a universe that are
/// currently on a point-and-figure buy signal.
///
/// On each [`CrossSection`] tick the value is `100 * on_buy_signal_count /
/// universe size`, read from the per-symbol `on_buy_signal` flag (the caller
/// evaluates each symbol's point-and-figure chart when it builds the tick). It is
/// a bounded `0..=100` gauge of how many issues are in a confirmed uptrend.
/// Readings above 70 are considered overbought (broad strength, but a crowded
/// market) and below 30 oversold; reversals from those zones are classic BPI
/// buy/sell triggers.
///
/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
/// `warmup_period == 1`. The universe is non-empty by construction, so the share
/// is always defined.
///
/// # Example
///
/// ```
/// use wickra_core::{BullishPercentIndex, CrossSection, Indicator, Member};
///
/// let mut bpi = BullishPercentIndex::new();
/// // 2 of 4 symbols on a buy signal -> 50%.
/// let tick = CrossSection::new(
/// vec![
/// Member::with_signals(1.0, 10.0, false, false, false, true),
/// Member::with_signals(1.0, 10.0, false, false, false, true),
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(bpi.update(tick), Some(50.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct BullishPercentIndex {
has_emitted: bool,
}
impl BullishPercentIndex {
/// Construct a new Bullish Percent Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for BullishPercentIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let bullish = section.on_buy_signal_count() as f64;
let total = section.members.len() as f64;
self.has_emitted = true;
Some(100.0 * bullish / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"BullishPercentIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(bullish: usize, bearish: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..bullish {
members.push(Member::with_signals(1.0, 10.0, false, false, false, true));
}
for _ in 0..bearish {
members.push(Member::with_signals(-1.0, 10.0, false, false, false, false));
}
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let bpi = BullishPercentIndex::new();
assert_eq!(bpi.name(), "BullishPercentIndex");
assert_eq!(bpi.warmup_period(), 1);
assert!(!bpi.is_ready());
}
#[test]
fn first_tick_emits_percentage() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(2, 2)), Some(50.0));
assert!(bpi.is_ready());
}
#[test]
fn all_bullish_is_one_hundred() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(5, 0)), Some(100.0));
}
#[test]
fn none_bullish_is_zero() {
let mut bpi = BullishPercentIndex::new();
assert_eq!(bpi.update(tick(0, 4)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut bpi = BullishPercentIndex::new();
bpi.update(tick(2, 2));
assert!(bpi.is_ready());
bpi.reset();
assert!(!bpi.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![tick(2, 2), tick(5, 0), tick(0, 4)];
let mut a = BullishPercentIndex::new();
let mut b = BullishPercentIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,163 @@
//! Cumulative Volume Index — running total of volume-normalised net advancing volume.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Cumulative Volume Index (CVI) — the running total of *volume-normalised* net
/// advancing volume across a universe.
///
/// On each [`CrossSection`] tick the increment is `(advancing volume - declining
/// volume) / total volume`: the share of the tick's total volume that flowed,
/// net, into advancing issues. The index accumulates this share over time. Where
/// the raw [`AdVolumeLine`](crate::AdVolumeLine) sums *absolute* net volume — and
/// so drifts with secular growth in trading activity — the CVI normalises each
/// tick by its own total volume, so a one-share-net day in a thin market counts
/// the same as in a heavy one. This keeps the index comparable across regimes of
/// very different volume.
///
/// When a tick has zero total volume the net is necessarily zero too, so the
/// increment is zero and the index is unchanged (the divisor is floored to the
/// smallest positive `f64` purely to keep the division defined).
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, CumulativeVolumeIndex, Indicator, Member};
///
/// let mut cvi = CumulativeVolumeIndex::new();
/// // adv vol 150, dec vol 50, total 200 -> (150 - 50) / 200 = 0.5.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 150.0, false, false),
/// Member::new(-1.0, 50.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(cvi.update(tick), Some(0.5));
/// ```
#[derive(Debug, Clone, Default)]
pub struct CumulativeVolumeIndex {
index: f64,
has_emitted: bool,
}
impl CumulativeVolumeIndex {
/// Construct a new Cumulative Volume Index indicator.
#[must_use]
pub const fn new() -> Self {
Self {
index: 0.0,
has_emitted: false,
}
}
}
impl Indicator for CumulativeVolumeIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.advancing_volume() - section.declining_volume();
let total = section.total_volume().max(f64::MIN_POSITIVE);
self.index += net / total;
self.has_emitted = true;
Some(self.index)
}
fn reset(&mut self) {
self.index = 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 {
"CumulativeVolumeIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(items: &[(f64, f64)]) -> CrossSection {
CrossSection::new(
items
.iter()
.map(|&(change, volume)| Member::new(change, volume, false, false))
.collect(),
0,
)
.unwrap()
}
#[test]
fn accessors_and_metadata() {
let cvi = CumulativeVolumeIndex::new();
assert_eq!(cvi.name(), "CumulativeVolumeIndex");
assert_eq!(cvi.warmup_period(), 1);
assert!(!cvi.is_ready());
}
#[test]
fn first_tick_emits_normalised_net() {
let mut cvi = CumulativeVolumeIndex::new();
assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
assert!(cvi.is_ready());
}
#[test]
fn index_accumulates_normalised_shares() {
let mut cvi = CumulativeVolumeIndex::new();
assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
// adv 60, dec 60, total 120 -> net 0 -> index unchanged.
assert_eq!(cvi.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(0.5));
}
#[test]
fn zero_total_volume_leaves_index_unchanged() {
let mut cvi = CumulativeVolumeIndex::new();
cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
// A tick with no volume at all: net 0 / floored divisor -> 0 increment.
assert_eq!(cvi.update(tick(&[(0.0, 0.0)])), Some(0.5));
}
#[test]
fn reset_clears_state() {
let mut cvi = CumulativeVolumeIndex::new();
cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
assert!(cvi.is_ready());
cvi.reset();
assert!(!cvi.is_ready());
assert_eq!(cvi.update(tick(&[(1.0, 100.0)])), Some(1.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
tick(&[(1.0, 150.0), (-1.0, 50.0)]),
tick(&[(1.0, 60.0), (-1.0, 60.0)]),
tick(&[(0.0, 0.0)]),
];
let mut a = CumulativeVolumeIndex::new();
let mut b = CumulativeVolumeIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -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);
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Directional Movement Index (DX), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Directional Movement Index (`DX`).
///
/// `DX = 100 · |+DI DI| / (+DI + DI)`, the un-smoothed precursor to
/// [`Adx`](crate::Adx) (which is the Wilder average of `DX`). Both directional
/// indicators are derived from Wilder-smoothed `+DM`, `DM` and true range over
/// `period` bars, so the first value is emitted after `period + 1` candles.
///
/// `DX` ranges over `[0, 100]`: high when one side of the directional system
/// clearly dominates (a strong trend) and near zero when `+DI` and `DI` are
/// balanced (a range). When both directional indicators are zero — a perfectly
/// flat market — the index returns `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Dx};
///
/// let mut indicator = Dx::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Dx {
period: usize,
prev: Option<Candle>,
plus_dm_seed: f64,
minus_dm_seed: f64,
tr_seed: f64,
seed_count: usize,
plus_dm_smooth: Option<f64>,
minus_dm_smooth: Option<f64>,
tr_smooth: Option<f64>,
}
impl Dx {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
plus_dm_seed: 0.0,
minus_dm_seed: 0.0,
tr_seed: 0.0,
seed_count: 0,
plus_dm_smooth: None,
minus_dm_smooth: None,
tr_smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Dx {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (plus_dm, minus_dm) = directional_movement(&prev, &candle);
let tr = candle.true_range(Some(prev.close));
let n = self.period as f64;
let (plus_v, minus_v, tr_v) = if let (Some(p), Some(m), Some(t)) =
(self.plus_dm_smooth, self.minus_dm_smooth, self.tr_smooth)
{
let p_new = p - p / n + plus_dm;
let m_new = m - m / n + minus_dm;
let t_new = t - t / n + tr;
self.plus_dm_smooth = Some(p_new);
self.minus_dm_smooth = Some(m_new);
self.tr_smooth = Some(t_new);
(p_new, m_new, t_new)
} else {
self.plus_dm_seed += plus_dm;
self.minus_dm_seed += minus_dm;
self.tr_seed += tr;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.plus_dm_smooth = Some(self.plus_dm_seed);
self.minus_dm_smooth = Some(self.minus_dm_seed);
self.tr_smooth = Some(self.tr_seed);
(self.plus_dm_seed, self.minus_dm_seed, self.tr_seed)
};
let (plus_di, minus_di) = if tr_v == 0.0 {
(0.0, 0.0)
} else {
(100.0 * plus_v / tr_v, 100.0 * minus_v / tr_v)
};
let di_sum = plus_di + minus_di;
let dx = if di_sum == 0.0 {
0.0
} else {
100.0 * (plus_di - minus_di).abs() / di_sum
};
Some(dx)
}
fn reset(&mut self) {
self.prev = None;
self.plus_dm_seed = 0.0;
self.minus_dm_seed = 0.0;
self.tr_seed = 0.0;
self.seed_count = 0;
self.plus_dm_smooth = None;
self.minus_dm_smooth = None;
self.tr_smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.tr_smooth.is_some()
}
fn name(&self) -> &'static str {
"DX"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(Dx::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let dx = Dx::new(7).unwrap();
assert_eq!(dx.period(), 7);
assert_eq!(dx.name(), "DX");
assert_eq!(dx.warmup_period(), 7);
assert!(!dx.is_ready());
}
#[test]
fn strong_trend_drives_dx_high() {
// A clean uptrend has one-sided directional movement, so DX is large.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut dx = Dx::new(3).unwrap();
let out: Vec<Option<f64>> = dx.batch(&candles);
assert_eq!(out[0], None);
assert!(out[3].is_some());
let last = out.into_iter().flatten().last().unwrap();
assert!(last > 50.0 && last <= 100.0);
assert!(dx.is_ready());
}
#[test]
fn flat_market_returns_zero() {
// Both directional indicators collapse to zero -> DX is zero.
let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
let mut dx = Dx::new(3).unwrap();
let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn balanced_directional_movement_is_low() {
// Alternating up and down bars of equal magnitude keep +DI and -DI close,
// so DX stays well below a trending reading.
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = if i % 2 == 0 { 100.0 } else { 101.0 };
c(base + 1.0, base - 1.0, base)
})
.collect();
let mut dx = Dx::new(5).unwrap();
let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
assert!((0.0..=100.0).contains(&last));
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut dx = Dx::new(3).unwrap();
let _ = dx.batch(&candles);
assert!(dx.is_ready());
dx.reset();
assert!(!dx.is_ready());
assert_eq!(dx.update(candles[0]), None);
}
}
@@ -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,162 @@
//! High-Low Index — a moving average of the record-high percentage.
use crate::cross_section::CrossSection;
use crate::error::Result;
use crate::traits::Indicator;
use crate::Sma;
/// High-Low Index — a simple moving average of the *record high percent*,
/// `100 * new_highs / (new_highs + new_lows)`.
///
/// The record high percent is the share of new-extreme issues that are new
/// *highs* rather than new *lows*; smoothing it over a window (the classic period
/// is 10) gives the High-Low Index. Readings above 50 mean new highs dominate
/// (a healthy, broadening trend), readings below 50 mean new lows dominate. The
/// 30 and 70 lines are watched as oversold / overbought breadth thresholds.
///
/// Each tick floors the new-extreme count to one, so a tick with no new highs or
/// lows contributes a defined `0.0` instead of dividing by zero. The reading is
/// `None` until `period` ticks have been seen.
///
/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
/// `warmup_period == period`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, HighLowIndex, Indicator, Member};
///
/// let mut hli = HighLowIndex::new(2).unwrap();
/// let highs = CrossSection::new(vec![Member::new(1.0, 1.0, true, false)], 0).unwrap();
/// assert_eq!(hli.update(highs.clone()), None); // warming up
/// assert_eq!(hli.update(highs), Some(100.0)); // all new highs
/// ```
#[derive(Debug, Clone)]
pub struct HighLowIndex {
sma: Sma,
}
impl HighLowIndex {
/// Construct a new High-Low Index over the given window length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
sma: Sma::new(period)?,
})
}
/// Configured window length.
#[must_use]
pub const fn period(&self) -> usize {
self.sma.period()
}
}
impl Indicator for HighLowIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let new_highs = section.new_highs();
let new_lows = section.new_lows();
let extremes = (new_highs + new_lows).max(1) as f64;
let record_high_percent = 100.0 * new_highs as f64 / extremes;
self.sma.update(record_high_percent)
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.sma.period()
}
fn is_ready(&self) -> bool {
self.sma.value().is_some()
}
fn name(&self) -> &'static str {
"HighLowIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::error::Error;
use crate::traits::BatchExt;
fn flags(highs: usize, lows: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..highs {
members.push(Member::new(1.0, 10.0, true, false));
}
for _ in 0..lows {
members.push(Member::new(-1.0, 10.0, false, true));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let hli = HighLowIndex::new(10).unwrap();
assert_eq!(hli.name(), "HighLowIndex");
assert_eq!(hli.warmup_period(), 10);
assert_eq!(hli.period(), 10);
assert!(!hli.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(matches!(HighLowIndex::new(0), Err(Error::PeriodZero)));
}
#[test]
fn averages_the_record_high_percent() {
let mut hli = HighLowIndex::new(2).unwrap();
// 8 highs / 10 extremes -> 80% ; window not full.
assert_eq!(hli.update(flags(8, 2)), None);
// 6 highs / 10 extremes -> 60% ; SMA(2) = (80 + 60) / 2 = 70.
let value = hli.update(flags(6, 4)).unwrap();
assert!((value - 70.0).abs() < 1e-9);
assert!(hli.is_ready());
}
#[test]
fn no_extremes_floors_to_zero_percent() {
let mut hli = HighLowIndex::new(1).unwrap();
// No new highs or lows -> 0 / max(0, 1) -> 0%.
assert_eq!(hli.update(flags(0, 0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut hli = HighLowIndex::new(2).unwrap();
hli.update(flags(8, 2));
hli.update(flags(6, 4));
assert!(hli.is_ready());
hli.reset();
assert!(!hli.is_ready());
assert_eq!(hli.update(flags(8, 2)), None);
}
#[test]
fn batch_equals_streaming() {
let sections = vec![flags(8, 2), flags(6, 4), flags(3, 7), flags(0, 0)];
let mut a = HighLowIndex::new(2).unwrap();
let mut b = HighLowIndex::new(2).unwrap();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,303 @@
//! Ehlers Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// Ehlers' Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) to recover the dominant
/// cycle period, then measures the **phase angle** of that cycle (in degrees) by
/// correlating the smoothed price over one dominant-cycle window against a unit
/// phasor. The phase advances roughly linearly through a clean cycle and stalls
/// in a trend, which is the basis of Ehlers' trend-versus-cycle detection.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_DCPHASE`. The first value is emitted after ~50 inputs, once the engine's
/// moving-average chain has filled.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtDcPhase};
///
/// let mut ht = HtDcPhase::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtDcPhase {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
// Longer history of the 4-bar smoothed price, used to integrate the phase
// over one dominant-cycle window (up to 50 bars).
smooth_price: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_smooth_period: f64,
count: usize,
last_value: Option<f64>,
}
impl HtDcPhase {
/// Construct a new Hilbert transform dominant-cycle phase estimator.
pub fn new() -> Self {
Self::default()
}
/// Current dominant-cycle phase (degrees) if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtDcPhase {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
Self::push_front(&mut self.smooth_price, smooth, 50);
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
if self.count < 50 {
return None;
}
// Integrate the smoothed price over one dominant-cycle window against a
// unit phasor to recover the instantaneous dominant-cycle phase.
let smooth_period = self.prev_smooth_period;
let dc_period = (smooth_period + 0.5) as usize;
let dc_period = dc_period.clamp(1, self.smooth_price.len());
let mut real_part = 0.0;
let mut imag_part = 0.0;
for i in 0..dc_period {
let angle = (i as f64) * 2.0 * PI / (dc_period as f64);
let sp = self.smooth_price[i];
real_part += angle.sin() * sp;
imag_part += angle.cos() * sp;
}
let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period);
self.last_value = Some(dc_phase);
Some(dc_phase)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.smooth_price.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_smooth_period = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"HT_DCPHASE"
}
}
/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of
/// the one-cycle homodyne integration, then unwraps it into TA-Lib's
/// `[-45, 315)` output range with the 4-bar smoother group-delay correction.
///
/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the
/// phase collapses to `±90°` by the sign of `real_part`.
fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 {
let mut dc_phase = if imag_part.abs() > 0.001 {
(real_part / imag_part).atan().to_degrees()
} else if real_part < 0.0 {
-90.0
} else {
90.0
};
dc_phase += 90.0;
// Compensate the group delay of the 4-bar weighted smoother.
dc_phase += 360.0 / smooth_period;
if imag_part < 0.0 {
dc_phase += 180.0;
}
if dc_phase > 315.0 {
dc_phase -= 360.0;
}
dc_phase
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn sine_prices(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
.collect()
}
#[test]
fn accessors_and_metadata() {
let ht = HtDcPhase::new();
assert_eq!(ht.warmup_period(), 50);
assert_eq!(ht.name(), "HT_DCPHASE");
assert!(!ht.is_ready());
}
#[test]
fn near_zero_imaginary_collapses_to_signed_ninety() {
// A near-zero imaginary part makes atan(real/imag) undefined, so the phase
// collapses to +90 for non-negative real and -90 for negative real before
// the +90 offset and group-delay correction unwrap it.
let pos = compute_dc_phase(1.0, 0.0, 20.0);
let neg = compute_dc_phase(-1.0, 0.0, 20.0);
assert!((pos - 198.0).abs() < 1e-9);
assert!((neg - 18.0).abs() < 1e-9);
// The normal path still flows through atan.
let mid = compute_dc_phase(1.0, 1.0, 20.0);
assert!((mid - 153.0).abs() < 1e-9);
}
#[test]
fn emits_after_warmup_within_phase_band() {
let mut ht = HtDcPhase::new();
let out: Vec<Option<f64>> = ht.batch(&sine_prices(200));
assert_eq!(out[0], None);
assert!(ht.is_ready());
for v in out.into_iter().flatten() {
assert!(v.is_finite(), "phase must be finite");
assert!((-360.0..=360.0).contains(&v), "phase {v} outside band");
}
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtDcPhase::new();
let _ = ht.batch(&sine_prices(120));
let before = ht.value();
assert_eq!(ht.update(f64::NAN), before);
}
#[test]
fn batch_equals_streaming() {
let prices = sine_prices(200);
let mut a = HtDcPhase::new();
let mut b = HtDcPhase::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtDcPhase::new();
let _ = ht.batch(&sine_prices(120));
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -0,0 +1,240 @@
//! Ehlers Hilbert Transform Phasor components (`HT_PHASOR`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// In-phase and quadrature components of the Hilbert transform phasor.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HtPhasorOutput {
/// In-phase component (`I1`).
pub inphase: f64,
/// Quadrature component (`Q1`).
pub quadrature: f64,
}
/// Ehlers' Hilbert Transform Phasor (`HT_PHASOR`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) but reports the raw
/// in-phase (`I1`) and quadrature (`Q1`) components of the analytic signal rather
/// than the recovered cycle period. The two components are 90° out of phase, so
/// their ratio tracks the instantaneous phase of the dominant cycle.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_PHASOR`. The first value is emitted once the transform's tap buffers fill.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtPhasor};
///
/// let mut ht = HtPhasor::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtPhasor {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
ready: bool,
}
impl HtPhasor {
/// Construct a new Hilbert transform phasor.
pub fn new() -> Self {
Self::default()
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtPhasor {
type Input = f64;
type Output = HtPhasorOutput;
fn update(&mut self, input: f64) -> Option<HtPhasorOutput> {
if !input.is_finite() {
return None;
}
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
// Continue the dominant-cycle period adaptation so the next bar's `adj`
// coefficient tracks the cycle, exactly as TA-Lib's HT_PHASOR does.
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.ready = true;
Some(HtPhasorOutput {
inphase: i1,
quadrature: q1,
})
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.ready = false;
}
fn warmup_period(&self) -> usize {
19
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"HT_PHASOR"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn sine_prices(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
.collect()
}
#[test]
fn accessors_and_metadata() {
let ht = HtPhasor::new();
assert_eq!(ht.warmup_period(), 19);
assert_eq!(ht.name(), "HT_PHASOR");
assert!(!ht.is_ready());
}
#[test]
fn emits_after_warmup_and_stays_finite() {
let mut ht = HtPhasor::new();
let out: Vec<Option<HtPhasorOutput>> = ht.batch(&sine_prices(120));
assert_eq!(out[0], None);
let first = out.iter().position(Option::is_some).expect("emits");
assert!(first <= 19, "first phasor at index {first}");
for o in out.into_iter().flatten() {
assert!(o.inphase.is_finite() && o.quadrature.is_finite());
}
assert!(ht.is_ready());
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtPhasor::new();
let _ = ht.batch(&sine_prices(120));
// A non-finite input is skipped and produces no value.
assert_eq!(ht.update(f64::NAN), None);
}
#[test]
fn batch_equals_streaming() {
let prices = sine_prices(150);
let mut a = HtPhasor::new();
let mut b = HtPhasor::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtPhasor::new();
let _ = ht.batch(&sine_prices(120));
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -0,0 +1,379 @@
//! Ehlers Hilbert Transform Trend vs Cycle Mode (`HT_TRENDMODE`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// Ehlers' Hilbert Transform Trend Mode (`HT_TRENDMODE`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle), derives the dominant
/// cycle phase, its sine / lead-sine, and an instantaneous trendline, then
/// classifies the market into **trend mode (`1`)** or **cycle mode (`0`)**:
///
/// - it is a *cycle* shortly after the sine and lead-sine cross, while the phase
/// advances at roughly the dominant-cycle rate;
/// - it is a *trend* otherwise, and is forced to trend whenever price separates
/// from the trendline by more than 1.5%.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_TRENDMODE`. The output is `1.0` or `0.0`; the first value is emitted after
/// ~50 inputs once the engine's moving-average chain has filled.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtTrendMode};
///
/// let mut ht = HtTrendMode::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtTrendMode {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
smooth_price: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_smooth_period: f64,
// Trend-mode state.
prev_dc_phase: f64,
prev_sine: f64,
prev_lead_sine: f64,
days_in_trend: f64,
it1: f64,
it2: f64,
it3: f64,
count: usize,
last_value: Option<f64>,
}
impl HtTrendMode {
/// Construct a new Hilbert transform trend-mode classifier.
pub fn new() -> Self {
Self::default()
}
/// Current trend-mode flag (`1.0` trend, `0.0` cycle) if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtTrendMode {
type Input = f64;
type Output = f64;
#[allow(clippy::too_many_lines)]
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
Self::push_front(&mut self.smooth_price, smooth, 50);
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
let smooth_period = self.prev_smooth_period;
let dc_period = ((smooth_period + 0.5) as usize).clamp(1, self.smooth_price.len());
// Dominant-cycle phase over one cycle window.
let mut real_part = 0.0;
let mut imag_part = 0.0;
for i in 0..dc_period {
let angle = (i as f64) * 2.0 * PI / (dc_period as f64);
let sp = self.smooth_price[i];
real_part += angle.sin() * sp;
imag_part += angle.cos() * sp;
}
let dc_phase = compute_dc_phase(real_part, imag_part, smooth_period);
let sine = (dc_phase * PI / 180.0).sin();
let lead_sine = ((dc_phase + 45.0) * PI / 180.0).sin();
// Instantaneous trendline: average smoothed price over the cycle window,
// then a 4-3-2-1 weighted smoothing of that running average.
let mut trend_sum = 0.0;
for i in 0..dc_period {
trend_sum += self.smooth_price[i];
}
trend_sum /= dc_period as f64;
let trendline = (4.0 * trend_sum + 3.0 * self.it1 + 2.0 * self.it2 + self.it3) / 10.0;
self.it3 = self.it2;
self.it2 = self.it1;
self.it1 = trend_sum;
// Trend / cycle decision (assume trend, override to cycle).
let mut trend = 1.0_f64;
// A crossing of sine and lead-sine restarts the cycle clock.
if (sine > lead_sine && self.prev_sine <= self.prev_lead_sine)
|| (sine < lead_sine && self.prev_sine >= self.prev_lead_sine)
{
self.days_in_trend = 0.0;
trend = 0.0;
}
self.days_in_trend += 1.0;
if self.days_in_trend < 0.5 * smooth_period {
trend = 0.0;
}
// Cycle mode while the phase advances at roughly the dominant-cycle rate.
let delta_phase = dc_phase - self.prev_dc_phase;
if smooth_period != 0.0
&& delta_phase > 0.67 * 360.0 / smooth_period
&& delta_phase < 1.5 * 360.0 / smooth_period
{
trend = 0.0;
}
// Force trend mode when price separates from the trendline.
if trendline != 0.0 && ((smooth - trendline) / trendline).abs() >= 0.015 {
trend = 1.0;
}
self.prev_dc_phase = dc_phase;
self.prev_sine = sine;
self.prev_lead_sine = lead_sine;
if self.count < 50 {
return None;
}
self.last_value = Some(trend);
Some(trend)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.smooth_price.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_smooth_period = 0.0;
self.prev_dc_phase = 0.0;
self.prev_sine = 0.0;
self.prev_lead_sine = 0.0;
self.days_in_trend = 0.0;
self.it1 = 0.0;
self.it2 = 0.0;
self.it3 = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"HT_TRENDMODE"
}
}
/// Recovers the dominant-cycle phase (degrees) from the real/imaginary parts of
/// the one-cycle homodyne integration, then unwraps it into TA-Lib's
/// `[-45, 315)` output range with the 4-bar smoother group-delay correction.
///
/// When `imag_part` is within `±0.001` of zero the `atan` is undefined, so the
/// phase collapses to `±90°` by the sign of `real_part`.
fn compute_dc_phase(real_part: f64, imag_part: f64, smooth_period: f64) -> f64 {
let mut dc_phase = if imag_part.abs() > 0.001 {
(real_part / imag_part).atan().to_degrees()
} else if real_part < 0.0 {
-90.0
} else {
90.0
};
dc_phase += 90.0;
dc_phase += 360.0 / smooth_period;
if imag_part < 0.0 {
dc_phase += 180.0;
}
if dc_phase > 315.0 {
dc_phase -= 360.0;
}
dc_phase
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
/// A trending ramp followed by a clean cycle, so both modes are exercised.
fn mixed_prices() -> Vec<f64> {
let mut v = Vec::new();
for i in 0..150 {
v.push(100.0 + f64::from(i) * 0.8);
}
for i in 0..200 {
v.push(220.0 + (f64::from(i) * 0.45).sin() * 12.0);
}
v
}
#[test]
fn accessors_and_metadata() {
let ht = HtTrendMode::new();
assert_eq!(ht.warmup_period(), 50);
assert_eq!(ht.name(), "HT_TRENDMODE");
assert!(!ht.is_ready());
assert!(ht.value().is_none());
}
#[test]
fn near_zero_imaginary_collapses_to_signed_ninety() {
// A near-zero imaginary part makes atan(real/imag) undefined, so the phase
// collapses to +90 for non-negative real and -90 for negative real before
// the +90 offset and group-delay correction unwrap it.
let pos = compute_dc_phase(1.0, 0.0, 20.0);
let neg = compute_dc_phase(-1.0, 0.0, 20.0);
assert!((pos - 198.0).abs() < 1e-9);
assert!((neg - 18.0).abs() < 1e-9);
// The normal path still flows through atan.
let mid = compute_dc_phase(1.0, 1.0, 20.0);
assert!((mid - 153.0).abs() < 1e-9);
}
#[test]
fn emits_binary_flag_and_visits_both_modes() {
let mut ht = HtTrendMode::new();
let out: Vec<Option<f64>> = ht.batch(&mixed_prices());
assert_eq!(out[0], None);
assert!(ht.is_ready());
let mut saw_trend = false;
let mut saw_cycle = false;
for v in out.into_iter().flatten() {
assert!(v == 0.0 || v == 1.0, "trend mode must be binary, got {v}");
if v == 1.0 {
saw_trend = true;
} else {
saw_cycle = true;
}
}
assert!(saw_trend, "ramp segment should report trend mode");
assert!(saw_cycle, "cycle segment should report cycle mode");
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtTrendMode::new();
let _ = ht.batch(&mixed_prices());
let before = ht.value();
assert_eq!(ht.update(f64::NAN), before);
}
#[test]
fn batch_equals_streaming() {
let prices = mixed_prices();
let mut a = HtTrendMode::new();
let mut b = HtTrendMode::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtTrendMode::new();
let _ = ht.batch(&mixed_prices());
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -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);
}
}
@@ -0,0 +1,170 @@
//! Linear Regression Intercept (`LINEARREG_INTERCEPT`).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Linear Regression Intercept (`LINEARREG_INTERCEPT`): the intercept `a` of the
/// rolling least-squares fit `y = a + b·x` over the last `period` inputs, indexed
/// `x = 0, 1, …, period 1`.
///
/// ```text
/// b (slope) = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// a (intercept) = (Σy b·Σx) / n
/// ```
///
/// Where [`LinearRegression`](crate::LinearRegression) reports the fitted line at
/// the most recent bar (`a + b·(period 1)`), this reports its value at the
/// *start* of the window (`x = 0`). Each update is O(1), maintaining the same
/// closed-form sliding-window sums as `LinearRegression`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, LinRegIntercept};
///
/// let mut indicator = LinRegIntercept::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct LinRegIntercept {
period: usize,
window: VecDeque<f64>,
sum_x: f64,
denom: f64,
sum_y: f64,
sum_xy: f64,
}
impl LinRegIntercept {
/// Construct a new rolling linear-regression intercept over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
/// undefined for fewer than two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "linear regression intercept needs period >= 2",
});
}
let n = period as f64;
let sum_x = n * (n - 1.0) / 2.0;
let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_x,
denom: n * sum_xx - sum_x * sum_x,
sum_y: 0.0,
sum_xy: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for LinRegIntercept {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
self.sum_y -= y0;
}
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom;
let intercept = (self.sum_y - slope * self.sum_x) / n;
Some(intercept)
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"LINEARREG_INTERCEPT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_short_period() {
assert!(matches!(
LinRegIntercept::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_report_config() {
let lr = LinRegIntercept::new(5).unwrap();
assert_eq!(lr.period(), 5);
assert_eq!(lr.name(), "LINEARREG_INTERCEPT");
assert_eq!(lr.warmup_period(), 5);
assert!(!lr.is_ready());
}
#[test]
fn reference_value() {
// period 3 over [1, 2, 9]: fit y = 0 + 4x, intercept = 0.
let mut lr = LinRegIntercept::new(3).unwrap();
let out: Vec<Option<f64>> = lr.batch(&[1.0, 2.0, 9.0]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-9);
assert!(lr.is_ready());
}
#[test]
fn slides_and_tracks_a_shifted_line() {
// After sliding to window [2, 9, 4]... intercept stays finite and the
// fit is exact for a clean line [10, 12, 14]: y = 10 + 2x, intercept 10.
let mut lr = LinRegIntercept::new(3).unwrap();
let out: Vec<Option<f64>> = lr.batch(&[1.0, 10.0, 12.0, 14.0]);
assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut lr = LinRegIntercept::new(3).unwrap();
let _ = lr.batch(&[1.0, 2.0, 9.0]);
assert!(lr.is_ready());
lr.reset();
assert!(!lr.is_ready());
assert_eq!(lr.update(1.0), None);
}
}
@@ -0,0 +1,286 @@
//! MACD with selectable moving-average types (MACDEXT).
use crate::error::{Error, Result};
use crate::indicators::dema::Dema;
use crate::indicators::ema::Ema;
use crate::indicators::macd::MacdOutput;
use crate::indicators::sma::Sma;
use crate::indicators::tema::Tema;
use crate::indicators::trima::Trima;
use crate::indicators::wma::Wma;
use crate::traits::Indicator;
/// Moving-average type selector for [`MacdExt`] and other multi-MA indicators.
///
/// The variants map to TA-Lib's `MA_Type` codes `0..=5` — the period-only
/// moving averages. (TA-Lib's KAMA / MAMA / T3 take additional shape parameters
/// and are not selectable here.)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaType {
/// Simple moving average (TA-Lib code `0`).
Sma,
/// Exponential moving average (TA-Lib code `1`).
Ema,
/// Weighted moving average (TA-Lib code `2`).
Wma,
/// Double exponential moving average (TA-Lib code `3`).
Dema,
/// Triple exponential moving average (TA-Lib code `4`).
Tema,
/// Triangular moving average (TA-Lib code `5`).
Trima,
}
impl MaType {
/// Map a TA-Lib `MA_Type` integer code (`0..=5`) to a [`MaType`].
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] for codes outside `0..=5` (the period-only
/// moving averages); codes `6..=8` (KAMA / MAMA / T3) are not supported.
pub fn from_code(code: u32) -> Result<Self> {
match code {
0 => Ok(Self::Sma),
1 => Ok(Self::Ema),
2 => Ok(Self::Wma),
3 => Ok(Self::Dema),
4 => Ok(Self::Tema),
5 => Ok(Self::Trima),
_ => Err(Error::InvalidPeriod {
message: "unsupported moving-average type code (expected 0..=5)",
}),
}
}
}
/// A concrete period-only moving average instance, dispatched by [`MaType`].
#[derive(Debug, Clone)]
enum Ma {
Sma(Sma),
Ema(Ema),
Wma(Wma),
Dema(Dema),
Tema(Tema),
Trima(Trima),
}
impl Ma {
fn new(kind: MaType, period: usize) -> Result<Self> {
Ok(match kind {
MaType::Sma => Self::Sma(Sma::new(period)?),
MaType::Ema => Self::Ema(Ema::new(period)?),
MaType::Wma => Self::Wma(Wma::new(period)?),
MaType::Dema => Self::Dema(Dema::new(period)?),
MaType::Tema => Self::Tema(Tema::new(period)?),
MaType::Trima => Self::Trima(Trima::new(period)?),
})
}
fn update(&mut self, value: f64) -> Option<f64> {
match self {
Self::Sma(m) => m.update(value),
Self::Ema(m) => m.update(value),
Self::Wma(m) => m.update(value),
Self::Dema(m) => m.update(value),
Self::Tema(m) => m.update(value),
Self::Trima(m) => m.update(value),
}
}
fn reset(&mut self) {
match self {
Self::Sma(m) => m.reset(),
Self::Ema(m) => m.reset(),
Self::Wma(m) => m.reset(),
Self::Dema(m) => m.reset(),
Self::Tema(m) => m.reset(),
Self::Trima(m) => m.reset(),
}
}
fn warmup_period(&self) -> usize {
match self {
Self::Sma(m) => m.warmup_period(),
Self::Ema(m) => m.warmup_period(),
Self::Wma(m) => m.warmup_period(),
Self::Dema(m) => m.warmup_period(),
Self::Tema(m) => m.warmup_period(),
Self::Trima(m) => m.warmup_period(),
}
}
}
/// MACD Extended (`MACDEXT`): MACD with an independently selectable
/// [`MaType`] for each of the fast, slow and signal lines.
///
/// Classic [`MacdIndicator`](crate::MacdIndicator) hard-wires the exponential
/// moving average everywhere; `MACDEXT` lets each line use any period-only
/// moving average. The MACD line is `fast_ma(price) slow_ma(price)`, the signal
/// line is `signal_ma(macd)`, and the histogram is `macd signal`. The first
/// full [`MacdOutput`] is emitted once the slow and signal averages are both warm.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MacdExt, MaType};
///
/// let mut indicator =
/// MacdExt::new(12, MaType::Ema, 26, MaType::Ema, 9, MaType::Sma).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MacdExt {
fast: Ma,
slow: Ma,
signal: Ma,
has_emitted: bool,
}
impl MacdExt {
/// Construct a MACDEXT with per-line periods and moving-average types.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if any period is zero and
/// [`Error::InvalidPeriod`] if `fast >= slow`, propagating any moving-average
/// construction error.
pub fn new(
fast: usize,
fast_type: MaType,
slow: usize,
slow_type: MaType,
signal: usize,
signal_type: MaType,
) -> Result<Self> {
if fast == 0 || slow == 0 || signal == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "fast period must be < slow period",
});
}
Ok(Self {
fast: Ma::new(fast_type, fast)?,
slow: Ma::new(slow_type, slow)?,
signal: Ma::new(signal_type, signal)?,
has_emitted: false,
})
}
}
impl Indicator for MacdExt {
type Input = f64;
type Output = MacdOutput;
fn update(&mut self, value: f64) -> Option<MacdOutput> {
let fast_v = self.fast.update(value);
let slow_v = self.slow.update(value);
let (Some(fast_v), Some(slow_v)) = (fast_v, slow_v) else {
return None;
};
let macd = fast_v - slow_v;
let signal = self.signal.update(macd)?;
self.has_emitted = true;
Some(MacdOutput {
macd,
signal,
histogram: macd - signal,
})
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.signal.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
self.slow.warmup_period() + self.signal.warmup_period()
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"MACDEXT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
const TYPES: [MaType; 6] = [
MaType::Sma,
MaType::Ema,
MaType::Wma,
MaType::Dema,
MaType::Tema,
MaType::Trima,
];
#[test]
fn from_code_maps_all_supported_types() {
assert_eq!(MaType::from_code(0).unwrap(), MaType::Sma);
assert_eq!(MaType::from_code(1).unwrap(), MaType::Ema);
assert_eq!(MaType::from_code(2).unwrap(), MaType::Wma);
assert_eq!(MaType::from_code(3).unwrap(), MaType::Dema);
assert_eq!(MaType::from_code(4).unwrap(), MaType::Tema);
assert_eq!(MaType::from_code(5).unwrap(), MaType::Trima);
assert!(MaType::from_code(6).is_err());
}
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
MacdExt::new(0, MaType::Ema, 26, MaType::Ema, 9, MaType::Ema),
Err(Error::PeriodZero)
));
assert!(matches!(
MacdExt::new(26, MaType::Ema, 12, MaType::Ema, 9, MaType::Ema),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let m = MacdExt::new(12, MaType::Ema, 26, MaType::Sma, 9, MaType::Sma).unwrap();
assert_eq!(m.name(), "MACDEXT");
assert!(!m.is_ready());
assert!(m.warmup_period() >= 26);
}
#[test]
fn every_ma_type_produces_a_consistent_histogram() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0)
.collect();
for &t in &TYPES {
let mut m = MacdExt::new(5, t, 10, t, 4, t).unwrap();
let out: Vec<Option<MacdOutput>> = m.batch(&prices);
assert!(out.iter().any(Option::is_some), "{t:?} never emitted");
for o in out.into_iter().flatten() {
assert!((o.histogram - (o.macd - o.signal)).abs() < 1e-9);
}
// Exercise the warmup accessor for this variant's inner averages.
assert!(m.warmup_period() >= 10);
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
}
}
#[test]
fn mixed_ma_types_per_line() {
let prices: Vec<f64> = (0..120).map(|i| 100.0 + f64::from(i)).collect();
let mut m = MacdExt::new(12, MaType::Wma, 26, MaType::Dema, 9, MaType::Trima).unwrap();
let last = m.batch(&prices).into_iter().flatten().last();
assert!(last.is_some());
}
}
@@ -0,0 +1,117 @@
//! MACD with fixed 12/26 periods (MACDFIX).
use crate::error::Result;
use crate::indicators::macd::{MacdIndicator, MacdOutput};
use crate::traits::Indicator;
/// MACD Fix (`MACDFIX`): the classic MACD with the fast and slow EMAs fixed at
/// 12 and 26, leaving only the signal period configurable.
///
/// This is TA-Lib's `MACDFIX` — identical output to
/// [`MacdIndicator::new(12, 26, signal)`](crate::MacdIndicator), packaged as a
/// single-parameter constructor for the common case. The output is the usual
/// [`MacdOutput`] triple `{ macd, signal, histogram }`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MacdFix};
///
/// let mut indicator = MacdFix::new(9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MacdFix {
inner: MacdIndicator,
}
impl MacdFix {
/// Construct a MACDFIX with fast = 12, slow = 26 and the given signal period.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `signal == 0`.
pub fn new(signal: usize) -> Result<Self> {
Ok(Self {
inner: MacdIndicator::new(12, 26, signal)?,
})
}
/// Configured signal period.
pub fn signal_period(&self) -> usize {
self.inner.periods().2
}
}
impl Indicator for MacdFix {
type Input = f64;
type Output = MacdOutput;
fn update(&mut self, value: f64) -> Option<MacdOutput> {
self.inner.update(value)
}
fn reset(&mut self) {
self.inner.reset();
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn name(&self) -> &'static str {
"MACDFIX"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_zero_signal() {
assert!(MacdFix::new(0).is_err());
}
#[test]
fn accessors_report_config() {
let m = MacdFix::new(9).unwrap();
assert_eq!(m.signal_period(), 9);
assert_eq!(m.name(), "MACDFIX");
assert!(!m.is_ready());
assert_eq!(
m.warmup_period(),
MacdIndicator::new(12, 26, 9).unwrap().warmup_period()
);
}
#[test]
fn matches_macd_with_fixed_periods() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let fix: Vec<Option<MacdOutput>> = MacdFix::new(9).unwrap().batch(&prices);
let classic: Vec<Option<MacdOutput>> =
MacdIndicator::new(12, 26, 9).unwrap().batch(&prices);
assert_eq!(fix, classic);
assert!(fix.iter().any(Option::is_some));
}
#[test]
fn reset_clears_state() {
let prices: Vec<f64> = (0..80).map(|i| 100.0 + f64::from(i)).collect();
let mut m = MacdFix::new(9).unwrap();
let _ = m.batch(&prices);
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
}
}
@@ -0,0 +1,204 @@
//! McClellan Oscillator — the spread between a fast and slow EMA of breadth.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Fast EMA smoothing constant — the classic McClellan 19-period weight
/// `2 / (19 + 1)`.
const ALPHA_FAST: f64 = 0.1;
/// Slow EMA smoothing constant — the classic McClellan 39-period weight
/// `2 / (39 + 1)`.
const ALPHA_SLOW: f64 = 0.05;
/// Scale applied to the ratio-adjusted net advances so readings land on the
/// familiar McClellan amplitude.
const RANA_SCALE: f64 = 1000.0;
/// McClellan Oscillator — the difference between a 19-period and a 39-period
/// exponential moving average of *ratio-adjusted net advances*.
///
/// Each tick's breadth is reduced to ratio-adjusted net advances (RANA),
/// `(advancers - decliners) / (advancers + decliners) * 1000`. Dividing by the
/// number of participating issues makes the reading independent of universe size,
/// so the oscillator stays comparable as the universe grows or shrinks. The
/// oscillator is then the fast EMA minus the slow EMA of that series, using the
/// classic McClellan smoothing constants `0.10` (19-period) and `0.05`
/// (39-period). Both EMAs are seeded from the first tick's RANA, so the
/// oscillator is defined from the first update (`warmup_period == 1`); it starts
/// at `0.0` and crosses zero as breadth momentum shifts.
///
/// A tick with no advancing or declining issues yields a RANA of `0.0` (the
/// participating count is floored to one).
///
/// `Input = CrossSection`, `Output = f64`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, McClellanOscillator, Member};
///
/// let mut osc = McClellanOscillator::new();
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(1.0, 10.0, false, false),
/// Member::new(1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// // First tick seeds both EMAs to the same value -> oscillator 0.
/// assert_eq!(osc.update(tick), Some(0.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct McClellanOscillator {
ema_fast: f64,
ema_slow: f64,
seeded: bool,
has_emitted: bool,
}
impl McClellanOscillator {
/// Construct a new McClellan Oscillator with the classic 19/39 smoothing.
#[must_use]
pub const fn new() -> Self {
Self {
ema_fast: 0.0,
ema_slow: 0.0,
seeded: false,
has_emitted: false,
}
}
/// Feed a cross-section tick and return the oscillator value, which is defined
/// on every tick. Shared with [`McClellanSummationIndex`] so the summation
/// index can accumulate the oscillator without an `Option` round-trip.
///
/// [`McClellanSummationIndex`]: crate::McClellanSummationIndex
pub(crate) fn step(&mut self, section: &CrossSection) -> f64 {
let advancers = section.advancers();
let decliners = section.decliners();
let net = advancers as f64 - decliners as f64;
let participating = (advancers + decliners).max(1) as f64;
let rana = net / participating * RANA_SCALE;
if self.seeded {
self.ema_fast += ALPHA_FAST * (rana - self.ema_fast);
self.ema_slow += ALPHA_SLOW * (rana - self.ema_slow);
} else {
self.ema_fast = rana;
self.ema_slow = rana;
self.seeded = true;
}
self.has_emitted = true;
self.ema_fast - self.ema_slow
}
}
impl Indicator for McClellanOscillator {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
Some(self.step(&section))
}
fn reset(&mut self) {
self.ema_fast = 0.0;
self.ema_slow = 0.0;
self.seeded = false;
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"McClellanOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: 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));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let osc = McClellanOscillator::new();
assert_eq!(osc.name(), "McClellanOscillator");
assert_eq!(osc.warmup_period(), 1);
assert!(!osc.is_ready());
}
#[test]
fn seeds_to_zero_on_first_tick() {
let mut osc = McClellanOscillator::new();
// RANA = (3 - 1) / 4 * 1000 = 500 ; both EMAs seed to 500 -> spread 0.
assert_eq!(osc.update(section(3, 1)), Some(0.0));
assert!(osc.is_ready());
}
#[test]
fn tracks_breadth_momentum_after_seeding() {
let mut osc = McClellanOscillator::new();
osc.update(section(3, 1)); // seed at RANA 500
// RANA = (1 - 3) / 4 * 1000 = -500.
// fast = 500 + 0.1 * (-1000) = 400 ; slow = 500 + 0.05 * (-1000) = 450.
let value = osc.update(section(1, 3)).unwrap();
assert!((value - (-50.0)).abs() < 1e-9);
// RANA = 0. fast = 400 + 0.1 * (-400) = 360 ; slow = 450 + 0.05 * (-450) = 427.5.
let value = osc.update(section(2, 2)).unwrap();
assert!((value - (-67.5)).abs() < 1e-9);
}
#[test]
fn empty_participation_yields_zero_rana() {
let mut osc = McClellanOscillator::new();
// No advancers or decliners -> RANA 0 ; seeds both EMAs to 0 -> spread 0.
assert_eq!(osc.update(section(0, 0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut osc = McClellanOscillator::new();
osc.update(section(3, 1));
osc.update(section(1, 3));
assert!(osc.is_ready());
osc.reset();
assert!(!osc.is_ready());
// After reset the next tick re-seeds to spread 0.
assert_eq!(osc.update(section(1, 3)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(3, 1), section(1, 3), section(2, 2), section(0, 0)];
let mut a = McClellanOscillator::new();
let mut b = McClellanOscillator::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,160 @@
//! McClellan Summation Index — the running total of the McClellan Oscillator.
use crate::cross_section::CrossSection;
use crate::indicators::mcclellan_oscillator::McClellanOscillator;
use crate::traits::Indicator;
/// McClellan Summation Index — the running cumulative sum of the
/// [`McClellanOscillator`].
///
/// Where the oscillator measures the *momentum* of breadth, the summation index
/// integrates it into a longer-term breadth trend: it rises while the oscillator
/// is positive and falls while it is negative, so it behaves like a slow,
/// smoothed advance/decline line. Sustained readings far above or below zero mark
/// strong bull or bear breadth regimes, and crosses of the zero line are read as
/// major trend changes.
///
/// The index embeds a [`McClellanOscillator`] and adds its value on every tick.
/// Because the oscillator seeds to `0.0` on the first tick, the summation index
/// also starts at `0.0` and is defined from the first update
/// (`warmup_period == 1`).
///
/// `Input = CrossSection`, `Output = f64`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, McClellanSummationIndex, Member};
///
/// let mut msi = McClellanSummationIndex::new();
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// // First tick: oscillator seeds to 0, so the summation index is 0.
/// assert_eq!(msi.update(tick), Some(0.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct McClellanSummationIndex {
oscillator: McClellanOscillator,
sum: f64,
has_emitted: bool,
}
impl McClellanSummationIndex {
/// Construct a new McClellan Summation Index.
#[must_use]
pub fn new() -> Self {
Self {
oscillator: McClellanOscillator::new(),
sum: 0.0,
has_emitted: false,
}
}
}
impl Indicator for McClellanSummationIndex {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let oscillator = self.oscillator.step(&section);
self.sum += oscillator;
self.has_emitted = true;
Some(self.sum)
}
fn reset(&mut self) {
self.oscillator.reset();
self.sum = 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 {
"McClellanSummationIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: 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));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let msi = McClellanSummationIndex::new();
assert_eq!(msi.name(), "McClellanSummationIndex");
assert_eq!(msi.warmup_period(), 1);
assert!(!msi.is_ready());
}
#[test]
fn first_tick_starts_at_zero() {
let mut msi = McClellanSummationIndex::new();
assert_eq!(msi.update(section(3, 1)), Some(0.0));
assert!(msi.is_ready());
}
#[test]
fn accumulates_the_oscillator() {
let mut msi = McClellanSummationIndex::new();
assert_eq!(msi.update(section(3, 1)), Some(0.0)); // osc 0 -> sum 0
// osc -50 -> sum -50.
let value = msi.update(section(1, 3)).unwrap();
assert!((value - (-50.0)).abs() < 1e-9);
// osc -67.5 -> sum -117.5.
let value = msi.update(section(2, 2)).unwrap();
assert!((value - (-117.5)).abs() < 1e-9);
}
#[test]
fn reset_clears_state() {
let mut msi = McClellanSummationIndex::new();
msi.update(section(3, 1));
msi.update(section(1, 3));
assert!(msi.is_ready());
msi.reset();
assert!(!msi.is_ready());
// Oscillator re-seeds, so the summation index restarts at 0.
assert_eq!(msi.update(section(1, 3)), Some(0.0));
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(3, 1), section(1, 3), section(2, 2)];
let mut a = McClellanSummationIndex::new();
let mut b = McClellanSummationIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,144 @@
//! Midpoint (MIDPOINT) over a rolling window of a scalar series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Midpoint (`MIDPOINT`): the average of the highest and lowest value of the
/// input series over the last `period` points.
///
/// ```text
/// MIDPOINT = (highest(value, period) + lowest(value, period)) / 2
/// ```
///
/// Where [`MidPrice`](crate::MidPrice) takes the window extremes from a candle's
/// high/low, `MIDPOINT` works on a single scalar stream (typically the close),
/// taking the max and min of that stream over the window. The first value is
/// emitted once `period` points have been seen.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MidPoint};
///
/// let mut indicator = MidPoint::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MidPoint {
period: usize,
window: VecDeque<f64>,
}
impl MidPoint {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MidPoint {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let highest = self
.window
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let lowest = self.window.iter().copied().fold(f64::INFINITY, f64::min);
Some(f64::midpoint(highest, lowest))
}
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 {
"MIDPOINT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(MidPoint::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let mp = MidPoint::new(7).unwrap();
assert_eq!(mp.period(), 7);
assert_eq!(mp.name(), "MIDPOINT");
assert_eq!(mp.warmup_period(), 7);
assert!(!mp.is_ready());
}
#[test]
fn averages_window_min_and_max() {
// Window {8, 12, 10}: highest 12, lowest 8 -> 10.
let mut mp = MidPoint::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&[8.0, 12.0, 10.0]);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_relative_eq!(out[2].unwrap(), 10.0, epsilon = 1e-12);
assert!(mp.is_ready());
}
#[test]
fn window_slides_and_drops_old_values() {
// After the 30 spike leaves the window, the midpoint falls back.
let mut mp = MidPoint::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&[30.0, 8.0, 12.0, 10.0]);
// Last window {8, 12, 10}: (12 + 8) / 2 = 10.
assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut mp = MidPoint::new(3).unwrap();
let _ = mp.batch(&[8.0, 12.0, 10.0]);
assert!(mp.is_ready());
mp.reset();
assert!(!mp.is_ready());
assert_eq!(mp.update(8.0), None);
}
}
@@ -0,0 +1,165 @@
//! Midpoint Price (MIDPRICE) over a rolling window of high/low extremes.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Midpoint Price (`MIDPRICE`): the average of the highest high and the lowest
/// low over the last `period` candles.
///
/// ```text
/// MIDPRICE = (highest(high, period) + lowest(low, period)) / 2
/// ```
///
/// Unlike [`MedianPrice`](crate::MedianPrice), which averages a single bar's own
/// high and low, `MIDPRICE` averages the *window* extremes — it is numerically
/// the centre line of [`Donchian`](crate::Donchian) channels, exposed as a
/// standalone scalar for TA-Lib parity. The first value is emitted once `period`
/// candles have been seen.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MidPrice};
///
/// let mut indicator = MidPrice::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MidPrice {
period: usize,
candles: VecDeque<Candle>,
}
impl MidPrice {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
candles: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MidPrice {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if self.candles.len() == self.period {
self.candles.pop_front();
}
self.candles.push_back(candle);
if self.candles.len() < self.period {
return None;
}
let highest = self
.candles
.iter()
.map(|c| c.high)
.fold(f64::NEG_INFINITY, f64::max);
let lowest = self
.candles
.iter()
.map(|c| c.low)
.fold(f64::INFINITY, f64::min);
Some(f64::midpoint(highest, lowest))
}
fn reset(&mut self) {
self.candles.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.candles.len() == self.period
}
fn name(&self) -> &'static str {
"MIDPRICE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(MidPrice::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let mp = MidPrice::new(7).unwrap();
assert_eq!(mp.period(), 7);
assert_eq!(mp.name(), "MIDPRICE");
assert_eq!(mp.warmup_period(), 7);
assert!(!mp.is_ready());
}
#[test]
fn averages_window_extremes() {
// Window highs {12, 14, 16}, lows {8, 9, 10}: highest 16, lowest 8 -> 12.
let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)];
let mut mp = MidPrice::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&candles);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-12);
assert!(mp.is_ready());
}
#[test]
fn window_slides_and_drops_old_extremes() {
// After the spike leaves the window the midpoint falls back.
let candles = [
c(30.0, 10.0, 20.0),
c(12.0, 8.0, 10.0),
c(14.0, 9.0, 11.0),
c(16.0, 10.0, 12.0),
];
let mut mp = MidPrice::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&candles);
// Last window {12,14,16}/{8,9,10}: (16 + 8) / 2 = 12.
assert_relative_eq!(out[3].unwrap(), 12.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)];
let mut mp = MidPrice::new(3).unwrap();
let _ = mp.batch(&candles);
assert!(mp.is_ready());
mp.reset();
assert!(!mp.is_ready());
assert_eq!(mp.update(candles[0]), None);
}
}
@@ -0,0 +1,199 @@
//! Minus Directional Indicator (-DI), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Minus Directional Indicator (`MINUS_DI`).
///
/// `-DI = 100 · smoothed(-DM) / smoothed(TR)`, where both the minus directional
/// movement and the true range are Wilder-smoothed over `period` bars. It is the
/// bearish half of the directional system that drives [`Adx`](crate::Adx);
/// readings above [`PlusDi`](crate::PlusDi) mark a down-trending regime.
///
/// The first `period` raw values seed the two running sums; from then on each
/// applies the Wilder recursion `smoothed smoothed / period + raw`. Because a
/// bar's directional movement and true range both need the previous bar, the
/// first value is emitted after `period + 1` candles. When the smoothed true
/// range is zero (a perfectly flat market) the indicator returns `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MinusDi};
///
/// let mut indicator = MinusDi::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 - f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base - 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MinusDi {
period: usize,
prev: Option<Candle>,
dm_seed: f64,
tr_seed: f64,
seed_count: usize,
dm_smooth: Option<f64>,
tr_smooth: Option<f64>,
}
impl MinusDi {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
dm_seed: 0.0,
tr_seed: 0.0,
seed_count: 0,
dm_smooth: None,
tr_smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MinusDi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (_, minus_dm) = directional_movement(&prev, &candle);
let tr = candle.true_range(Some(prev.close));
let n = self.period as f64;
let (dm_v, tr_v) = if let (Some(d), Some(t)) = (self.dm_smooth, self.tr_smooth) {
let d_new = d - d / n + minus_dm;
let t_new = t - t / n + tr;
self.dm_smooth = Some(d_new);
self.tr_smooth = Some(t_new);
(d_new, t_new)
} else {
self.dm_seed += minus_dm;
self.tr_seed += tr;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.dm_smooth = Some(self.dm_seed);
self.tr_smooth = Some(self.tr_seed);
(self.dm_seed, self.tr_seed)
};
let di = if tr_v == 0.0 {
0.0
} else {
100.0 * dm_v / tr_v
};
Some(di)
}
fn reset(&mut self) {
self.prev = None;
self.dm_seed = 0.0;
self.tr_seed = 0.0;
self.seed_count = 0;
self.dm_smooth = None;
self.tr_smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.dm_smooth.is_some()
}
fn name(&self) -> &'static str {
"MINUS_DI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(MinusDi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let di = MinusDi::new(7).unwrap();
assert_eq!(di.period(), 7);
assert_eq!(di.name(), "MINUS_DI");
assert_eq!(di.warmup_period(), 7);
assert!(!di.is_ready());
}
#[test]
fn downtrend_drives_minus_di_high() {
// Strict downtrend: -DM dominates, so -DI is large and bounded by 100.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 140.0 - f64::from(i) * 2.0;
c(base + 0.5, base - 1.0, base - 0.5)
})
.collect();
let mut di = MinusDi::new(3).unwrap();
let out: Vec<Option<f64>> = di.batch(&candles);
assert_eq!(out[0], None);
assert!(out[3].is_some());
let last = out.into_iter().flatten().last().unwrap();
assert!(last > 0.0 && last <= 100.0);
assert!(di.is_ready());
}
#[test]
fn flat_market_returns_zero() {
let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
let mut di = MinusDi::new(3).unwrap();
let last = di.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 140.0 - f64::from(i) * 2.0;
c(base + 0.5, base - 1.0, base - 0.5)
})
.collect();
let mut di = MinusDi::new(3).unwrap();
let _ = di.batch(&candles);
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(candles[0]), None);
}
}
@@ -0,0 +1,195 @@
//! Minus Directional Movement (-DM), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Minus Directional Movement (`MINUS_DM`).
///
/// The raw minus directional movement of a bar is `max(low_prev low, 0)` when
/// the down-move exceeds the up-move `high high_prev`, and `0` otherwise. This
/// indicator returns the Wilder-smoothed running total of that raw `-DM` over
/// `period` bars, the same accumulation that feeds [`Adx`](crate::Adx) and
/// [`MinusDi`](crate::MinusDi).
///
/// The first `period` raw values seed the sum; from then on each update applies
/// the Wilder recursion `smoothed smoothed / period + raw`. Because a bar's
/// directional movement needs the previous bar, the first value is emitted after
/// `period + 1` candles.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MinusDm};
///
/// let mut indicator = MinusDm::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 - f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base - 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MinusDm {
period: usize,
prev: Option<Candle>,
seed: f64,
seed_count: usize,
smooth: Option<f64>,
}
impl MinusDm {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
seed: 0.0,
seed_count: 0,
smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MinusDm {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (_, minus_dm) = directional_movement(&prev, &candle);
let n = self.period as f64;
if let Some(s) = self.smooth {
let s_new = s - s / n + minus_dm;
self.smooth = Some(s_new);
return Some(s_new);
}
self.seed += minus_dm;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.smooth = Some(self.seed);
Some(self.seed)
}
fn reset(&mut self) {
self.prev = None;
self.seed = 0.0;
self.seed_count = 0;
self.smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.smooth.is_some()
}
fn name(&self) -> &'static str {
"MINUS_DM"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle with explicit high/low; open and close are pinned to `cl`.
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(MinusDm::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let dm = MinusDm::new(7).unwrap();
assert_eq!(dm.period(), 7);
assert_eq!(dm.name(), "MINUS_DM");
assert_eq!(dm.warmup_period(), 7);
assert!(!dm.is_ready());
}
#[test]
fn seeds_then_smooths_a_constant_minus_dm() {
// Low falls by 1 each bar (down = +1); high falls by 0.5 each bar, so the
// up-move is negative and -DM equals the down-move (1.0) on every bar.
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
20.0 - 0.5 * f64::from(i),
18.0 - f64::from(i),
19.0 - f64::from(i),
)
})
.collect();
let mut dm = MinusDm::new(3).unwrap();
let out: Vec<Option<f64>> = dm.batch(&candles);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_eq!(out[2], None);
// Seed = sum of three unit -DM values.
assert_relative_eq!(out[3].unwrap(), 3.0, epsilon = 1e-12);
// Wilder step: 3 - 3/3 + 1 = 3.
assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
assert!(dm.is_ready());
}
#[test]
fn up_moves_contribute_zero() {
// Strict uptrend: lows rise, so every raw -DM is zero.
let candles: Vec<Candle> = (0..6)
.map(|i| c(20.0 + f64::from(i), 5.0 + f64::from(i), 12.0 + f64::from(i)))
.collect();
let mut dm = MinusDm::new(3).unwrap();
let last = dm.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
20.0 - 0.5 * f64::from(i),
18.0 - f64::from(i),
19.0 - f64::from(i),
)
})
.collect();
let mut dm = MinusDm::new(3).unwrap();
let _ = dm.batch(&candles);
assert!(dm.is_ready());
dm.reset();
assert!(!dm.is_ready());
assert_eq!(dm.update(candles[0]), None);
}
}
+143 -1
View File
@@ -5,12 +5,16 @@
//! from the crate root for convenience.
mod abandoned_baby;
mod absolute_breadth_index;
mod acceleration_bands;
mod accelerator_oscillator;
mod ad_oscillator;
mod ad_volume_line;
mod adaptive_cycle;
mod adl;
mod advance_block;
mod advance_decline;
mod advance_decline_ratio;
mod adx;
mod adxr;
mod alligator;
@@ -26,14 +30,18 @@ mod atr_bands;
mod atr_trailing_stop;
mod autocorrelation;
mod average_drawdown;
mod avg_price;
mod awesome_oscillator;
mod awesome_oscillator_histogram;
mod balance_of_power;
mod belt_hold;
mod beta;
mod beta_neutral_spread;
mod bollinger;
mod bollinger_bandwidth;
mod breadth_thrust;
mod breakaway;
mod bullish_percent_index;
mod calendar_spread;
mod calmar_ratio;
mod camarilla_pivots;
@@ -56,6 +64,7 @@ mod conditional_value_at_risk;
mod connors_rsi;
mod coppock;
mod counterattack;
mod cumulative_volume_index;
mod cvd;
mod cybernetic_cycle;
mod decycler;
@@ -65,6 +74,7 @@ mod demand_index;
mod demark_pivots;
mod depth_slope;
mod detrended_std_dev;
mod distance_ssd;
mod doji;
mod doji_star;
mod donchian;
@@ -74,6 +84,7 @@ mod downside_gap_three_methods;
mod dpo;
mod dragonfly_doji;
mod drawdown_duration;
mod dx;
mod ease_of_movement;
mod effective_spread;
mod ehlers_stochastic;
@@ -98,11 +109,13 @@ 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;
mod harami;
mod heikin_ashi;
mod high_low_index;
mod high_wave;
mod hikkake;
mod hikkake_modified;
@@ -111,6 +124,9 @@ mod hilo_activator;
mod historical_volatility;
mod hma;
mod homing_pigeon;
mod ht_dcphase;
mod ht_phasor;
mod ht_trendmode;
mod hurst_channel;
mod hurst_exponent;
mod ichimoku;
@@ -124,6 +140,7 @@ mod inverse_fisher_transform;
mod inverted_hammer;
mod jma;
mod kagi_bars;
mod kalman_hedge_ratio;
mod kama;
mod kelly_criterion;
mod keltner;
@@ -139,6 +156,7 @@ mod lead_lag_cross_correlation;
mod linreg;
mod linreg_angle;
mod linreg_channel;
mod linreg_intercept;
mod linreg_slope;
mod liquidation_features;
mod long_legged_doji;
@@ -146,6 +164,8 @@ mod long_line;
mod long_short_ratio;
mod ma_envelope;
mod macd;
mod macd_ext;
mod macd_fix;
mod mama;
mod market_facilitation_index;
mod marubozu;
@@ -153,15 +173,22 @@ mod mass_index;
mod mat_hold;
mod matching_low;
mod max_drawdown;
mod mcclellan_oscillator;
mod mcclellan_summation_index;
mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_price;
mod mfi;
mod microprice;
mod mid_point;
mod mid_price;
mod minus_di;
mod minus_dm;
mod mom;
mod morning_doji_star;
mod morning_evening_star;
mod natr;
mod new_highs_new_lows;
mod nvi;
mod ob_imbalance_full;
mod ob_imbalance_top1;
@@ -174,15 +201,19 @@ 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;
mod parkinson;
mod pearson_correlation;
mod percent_above_ma;
mod percent_b;
mod percentage_trailing_stop;
mod pgo;
mod piercing_dark_cloud;
mod plus_di;
mod plus_dm;
mod pmo;
mod point_and_figure_bars;
mod ppo;
@@ -199,12 +230,18 @@ mod renko_trailing_stop;
mod rickshaw_man;
mod rising_three_methods;
mod roc;
mod rocp;
mod rocr;
mod rocr100;
mod rogers_satchell;
mod rolling_correlation;
mod rolling_covariance;
mod roofing_filter;
mod rsi;
mod rvi;
mod rvi_volatility;
mod rwi;
mod sar_ext;
mod separating_lines;
mod sharpe_ratio;
mod shooting_star;
@@ -218,6 +255,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;
@@ -254,13 +293,16 @@ mod three_outside;
mod three_soldiers_or_crows;
mod three_stars_in_south;
mod thrusting;
mod tick_index;
mod tii;
mod tpo_profile;
mod trade_imbalance;
mod treynor_ratio;
mod trima;
mod trin;
mod trix;
mod true_range;
mod tsf;
mod tsi;
mod tsv;
mod ttm_squeeze;
@@ -270,11 +312,13 @@ mod typical_price;
mod ulcer_index;
mod ultimate_oscillator;
mod unique_three_river;
mod up_down_volume_ratio;
mod upside_gap_three_methods;
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;
@@ -300,12 +344,16 @@ mod zig_zag;
mod zlema;
pub use abandoned_baby::AbandonedBaby;
pub use absolute_breadth_index::AbsoluteBreadthIndex;
pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
pub use accelerator_oscillator::AcceleratorOscillator;
pub use ad_oscillator::AdOscillator;
pub use ad_volume_line::AdVolumeLine;
pub use adaptive_cycle::AdaptiveCycle;
pub use adl::Adl;
pub use advance_block::AdvanceBlock;
pub use advance_decline::AdvanceDecline;
pub use advance_decline_ratio::AdvanceDeclineRatio;
pub use adx::{Adx, AdxOutput};
pub use adxr::Adxr;
pub use alligator::{Alligator, AlligatorOutput};
@@ -321,14 +369,18 @@ pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use autocorrelation::Autocorrelation;
pub use average_drawdown::AverageDrawdown;
pub use avg_price::AvgPrice;
pub use awesome_oscillator::AwesomeOscillator;
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 breadth_thrust::BreadthThrust;
pub use breakaway::Breakaway;
pub use bullish_percent_index::BullishPercentIndex;
pub use calendar_spread::CalendarSpread;
pub use calmar_ratio::CalmarRatio;
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
@@ -351,6 +403,7 @@ pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
pub use coppock::Coppock;
pub use counterattack::Counterattack;
pub use cumulative_volume_index::CumulativeVolumeIndex;
pub use cvd::CumulativeVolumeDelta;
pub use cybernetic_cycle::CyberneticCycle;
pub use decycler::Decycler;
@@ -360,6 +413,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};
@@ -369,6 +423,7 @@ pub use downside_gap_three_methods::DownsideGapThreeMethods;
pub use dpo::Dpo;
pub use dragonfly_doji::DragonflyDoji;
pub use drawdown_duration::DrawdownDuration;
pub use dx::Dx;
pub use ease_of_movement::EaseOfMovement;
pub use effective_spread::EffectiveSpread;
pub use ehlers_stochastic::EhlersStochastic;
@@ -393,11 +448,13 @@ 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;
pub use harami::Harami;
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
pub use high_low_index::HighLowIndex;
pub use high_wave::HighWave;
pub use hikkake::Hikkake;
pub use hikkake_modified::HikkakeModified;
@@ -406,6 +463,9 @@ pub use hilo_activator::HiLoActivator;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use homing_pigeon::HomingPigeon;
pub use ht_dcphase::HtDcPhase;
pub use ht_phasor::{HtPhasor, HtPhasorOutput};
pub use ht_trendmode::HtTrendMode;
pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use hurst_exponent::HurstExponent;
pub use ichimoku::{Ichimoku, IchimokuOutput};
@@ -419,6 +479,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};
@@ -434,6 +495,7 @@ pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrel
pub use linreg::LinearRegression;
pub use linreg_angle::LinRegAngle;
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
pub use linreg_intercept::LinRegIntercept;
pub use linreg_slope::LinRegSlope;
pub use liquidation_features::{LiquidationFeatures, LiquidationFeaturesOutput};
pub use long_legged_doji::LongLeggedDoji;
@@ -441,6 +503,8 @@ pub use long_line::LongLine;
pub use long_short_ratio::LongShortRatio;
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
pub use macd::{MacdIndicator, MacdOutput};
pub use macd_ext::{MaType, MacdExt};
pub use macd_fix::MacdFix;
pub use mama::{Mama, MamaOutput};
pub use market_facilitation_index::MarketFacilitationIndex;
pub use marubozu::Marubozu;
@@ -448,15 +512,22 @@ pub use mass_index::MassIndex;
pub use mat_hold::MatHold;
pub use matching_low::MatchingLow;
pub use max_drawdown::MaxDrawdown;
pub use mcclellan_oscillator::McClellanOscillator;
pub use mcclellan_summation_index::McClellanSummationIndex;
pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
pub use microprice::Microprice;
pub use mid_point::MidPoint;
pub use mid_price::MidPrice;
pub use minus_di::MinusDi;
pub use minus_dm::MinusDm;
pub use mom::Mom;
pub use morning_doji_star::MorningDojiStar;
pub use morning_evening_star::MorningEveningStar;
pub use natr::Natr;
pub use new_highs_new_lows::NewHighsNewLows;
pub use nvi::Nvi;
pub use ob_imbalance_full::OrderBookImbalanceFull;
pub use ob_imbalance_top1::OrderBookImbalanceTop1;
@@ -469,15 +540,19 @@ 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;
pub use parkinson::ParkinsonVolatility;
pub use pearson_correlation::PearsonCorrelation;
pub use percent_above_ma::PercentAboveMa;
pub use percent_b::PercentB;
pub use percentage_trailing_stop::PercentageTrailingStop;
pub use pgo::Pgo;
pub use piercing_dark_cloud::PiercingDarkCloud;
pub use plus_di::PlusDi;
pub use plus_dm::PlusDm;
pub use pmo::Pmo;
pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
pub use ppo::Ppo;
@@ -494,12 +569,18 @@ pub use renko_trailing_stop::RenkoTrailingStop;
pub use rickshaw_man::RickshawMan;
pub use rising_three_methods::RisingThreeMethods;
pub use roc::Roc;
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;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sar_ext::SarExt;
pub use separating_lines::SeparatingLines;
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
@@ -513,6 +594,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};
@@ -549,13 +632,16 @@ pub use three_outside::ThreeOutside;
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
pub use three_stars_in_south::ThreeStarsInSouth;
pub use thrusting::Thrusting;
pub use tick_index::TickIndex;
pub use tii::Tii;
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use treynor_ratio::TreynorRatio;
pub use trima::Trima;
pub use trin::Trin;
pub use trix::Trix;
pub use true_range::TrueRange;
pub use tsf::Tsf;
pub use tsi::Tsi;
pub use tsv::Tsv;
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
@@ -565,11 +651,13 @@ pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
pub use ultimate_oscillator::UltimateOscillator;
pub use unique_three_river::UniqueThreeRiver;
pub use up_down_volume_ratio::UpDownVolumeRatio;
pub use upside_gap_three_methods::UpsideGapThreeMethods;
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;
@@ -649,12 +737,17 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"LaguerreRsi",
"ConnorsRsi",
"Inertia",
"Rocp",
"Rocr",
"Rocr100",
],
),
(
"Trend & Directional",
&[
"MacdIndicator",
"MacdFix",
"MacdExt",
"Adx",
"Adxr",
"Aroon",
@@ -667,6 +760,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"MassIndex",
"ChoppinessIndex",
"VerticalHorizontalFilter",
"PlusDm",
"MinusDm",
"PlusDi",
"MinusDi",
"Dx",
],
),
(
@@ -738,6 +836,7 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"PercentageTrailingStop",
"StepTrailingStop",
"RenkoTrailingStop",
"SarExt",
],
),
(
@@ -787,6 +886,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",
],
),
(
@@ -798,6 +917,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"InverseFisherTransform",
"SuperSmoother",
"HilbertDominantCycle",
"HtDcPhase",
"HtPhasor",
"HtTrendMode",
"SineWave",
"Decycler",
"DecyclerOscillator",
@@ -976,6 +1098,26 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Alt-Chart Bars",
&["RenkoBars", "KagiBars", "PointAndFigureBars"],
),
(
"Market Breadth",
&[
"AdvanceDecline",
"AdvanceDeclineRatio",
"AdVolumeLine",
"McClellanOscillator",
"McClellanSummationIndex",
"Trin",
"BreadthThrust",
"NewHighsNewLows",
"HighLowIndex",
"PercentAboveMa",
"UpDownVolumeRatio",
"BullishPercentIndex",
"CumulativeVolumeIndex",
"AbsoluteBreadthIndex",
"TickIndex",
],
),
];
#[cfg(test)]
@@ -1004,6 +1146,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, 290, "FAMILIES total drifted from indicator count");
assert_eq!(total, 339, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,142 @@
//! New Highs New Lows — net count of fresh period extremes across a universe.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// New Highs New Lows — the number of symbols printing a new period high minus
/// the number printing a new period low across a universe.
///
/// On each [`CrossSection`] tick the value is `new_highs - new_lows`, read from the
/// per-symbol `new_high` / `new_low` flags. A persistently positive reading means
/// fresh leadership is broad (many names making new highs); a negative reading
/// during an index advance is a classic breadth divergence warning that the rally
/// is narrowing.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, Member, NewHighsNewLows};
///
/// let mut nhnl = NewHighsNewLows::new();
/// // 2 new highs, 1 new low -> net +1.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, true, false),
/// Member::new(1.0, 10.0, true, false),
/// Member::new(-1.0, 10.0, false, true),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(nhnl.update(tick), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct NewHighsNewLows {
has_emitted: bool,
}
impl NewHighsNewLows {
/// Construct a new New Highs New Lows indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for NewHighsNewLows {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let net = section.new_highs() as f64 - section.new_lows() as f64;
self.has_emitted = true;
Some(net)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"NewHighsNewLows"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn flags(highs: usize, lows: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..highs {
members.push(Member::new(1.0, 10.0, true, false));
}
for _ in 0..lows {
members.push(Member::new(-1.0, 10.0, false, true));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let nhnl = NewHighsNewLows::new();
assert_eq!(nhnl.name(), "NewHighsNewLows");
assert_eq!(nhnl.warmup_period(), 1);
assert!(!nhnl.is_ready());
}
#[test]
fn first_tick_emits_net_extremes() {
let mut nhnl = NewHighsNewLows::new();
assert_eq!(nhnl.update(flags(5, 2)), Some(3.0));
assert!(nhnl.is_ready());
}
#[test]
fn more_lows_than_highs_is_negative() {
let mut nhnl = NewHighsNewLows::new();
assert_eq!(nhnl.update(flags(1, 4)), Some(-3.0));
}
#[test]
fn no_extremes_yields_zero() {
let mut nhnl = NewHighsNewLows::new();
assert_eq!(nhnl.update(flags(0, 0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut nhnl = NewHighsNewLows::new();
nhnl.update(flags(3, 1));
assert!(nhnl.is_ready());
nhnl.reset();
assert!(!nhnl.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![flags(5, 2), flags(1, 4), flags(0, 0)];
let mut a = NewHighsNewLows::new();
let mut b = NewHighsNewLows::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -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,146 @@
//! Percent Above Moving Average — share of a universe trading above its MA.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Percent Above Moving Average — the percentage of symbols in a universe that
/// are trading above their reference moving average.
///
/// On each [`CrossSection`] tick the value is `100 * above_ma_count / universe
/// size`, read from the per-symbol `above_ma` flag (the caller decides which MA —
/// 50-day, 200-day — when it builds the tick). It is a bounded `0..=100` breadth
/// gauge: readings near 100 mean almost the whole universe is in an uptrend
/// (broad participation, but also a potential overbought extreme), readings near
/// zero mark washouts. Crosses of the 50 line are read as bull/bear regime flips.
///
/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
/// `warmup_period == 1`. The universe is non-empty by construction, so the share
/// is always defined.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, Member, PercentAboveMa};
///
/// let mut pct = PercentAboveMa::new();
/// // 3 of 4 symbols above their MA -> 75%.
/// let tick = CrossSection::new(
/// vec![
/// Member::with_signals(1.0, 10.0, false, false, true, false),
/// Member::with_signals(1.0, 10.0, false, false, true, false),
/// Member::with_signals(-1.0, 10.0, false, false, true, false),
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(pct.update(tick), Some(75.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct PercentAboveMa {
has_emitted: bool,
}
impl PercentAboveMa {
/// Construct a new Percent Above Moving Average indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for PercentAboveMa {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let above = section.above_ma_count() as f64;
let total = section.members.len() as f64;
self.has_emitted = true;
Some(100.0 * above / total)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"PercentAboveMa"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(above: usize, below: usize) -> CrossSection {
let mut members = Vec::new();
for _ in 0..above {
members.push(Member::with_signals(1.0, 10.0, false, false, true, false));
}
for _ in 0..below {
members.push(Member::with_signals(-1.0, 10.0, false, false, false, false));
}
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let pct = PercentAboveMa::new();
assert_eq!(pct.name(), "PercentAboveMa");
assert_eq!(pct.warmup_period(), 1);
assert!(!pct.is_ready());
}
#[test]
fn first_tick_emits_percentage() {
let mut pct = PercentAboveMa::new();
assert_eq!(pct.update(tick(3, 1)), Some(75.0));
assert!(pct.is_ready());
}
#[test]
fn all_above_is_one_hundred() {
let mut pct = PercentAboveMa::new();
assert_eq!(pct.update(tick(4, 0)), Some(100.0));
}
#[test]
fn none_above_is_zero() {
let mut pct = PercentAboveMa::new();
assert_eq!(pct.update(tick(0, 5)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut pct = PercentAboveMa::new();
pct.update(tick(3, 1));
assert!(pct.is_ready());
pct.reset();
assert!(!pct.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![tick(3, 1), tick(4, 0), tick(0, 5)];
let mut a = PercentAboveMa::new();
let mut b = PercentAboveMa::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,201 @@
//! Plus Directional Indicator (+DI), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Plus Directional Indicator (`PLUS_DI`).
///
/// `+DI = 100 · smoothed(+DM) / smoothed(TR)`, where both the plus directional
/// movement and the true range are Wilder-smoothed over `period` bars. It is the
/// bullish half of the directional system that drives [`Adx`](crate::Adx);
/// readings above [`MinusDi`](crate::MinusDi) mark an up-trending regime.
///
/// The first `period` raw values seed the two running sums; from then on each
/// applies the Wilder recursion `smoothed smoothed / period + raw`. Because a
/// bar's directional movement and true range both need the previous bar, the
/// first value is emitted after `period + 1` candles. When the smoothed true
/// range is zero (a perfectly flat market) the indicator returns `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, PlusDi};
///
/// let mut indicator = PlusDi::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct PlusDi {
period: usize,
prev: Option<Candle>,
dm_seed: f64,
tr_seed: f64,
seed_count: usize,
dm_smooth: Option<f64>,
tr_smooth: Option<f64>,
}
impl PlusDi {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
dm_seed: 0.0,
tr_seed: 0.0,
seed_count: 0,
dm_smooth: None,
tr_smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for PlusDi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (plus_dm, _) = directional_movement(&prev, &candle);
let tr = candle.true_range(Some(prev.close));
let n = self.period as f64;
let (dm_v, tr_v) = if let (Some(d), Some(t)) = (self.dm_smooth, self.tr_smooth) {
let d_new = d - d / n + plus_dm;
let t_new = t - t / n + tr;
self.dm_smooth = Some(d_new);
self.tr_smooth = Some(t_new);
(d_new, t_new)
} else {
self.dm_seed += plus_dm;
self.tr_seed += tr;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.dm_smooth = Some(self.dm_seed);
self.tr_smooth = Some(self.tr_seed);
(self.dm_seed, self.tr_seed)
};
let di = if tr_v == 0.0 {
0.0
} else {
100.0 * dm_v / tr_v
};
Some(di)
}
fn reset(&mut self) {
self.prev = None;
self.dm_seed = 0.0;
self.tr_seed = 0.0;
self.seed_count = 0;
self.dm_smooth = None;
self.tr_smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.dm_smooth.is_some()
}
fn name(&self) -> &'static str {
"PLUS_DI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(PlusDi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let di = PlusDi::new(7).unwrap();
assert_eq!(di.period(), 7);
assert_eq!(di.name(), "PLUS_DI");
assert_eq!(di.warmup_period(), 7);
assert!(!di.is_ready());
}
#[test]
fn uptrend_drives_plus_di_high() {
// Strict uptrend: +DM dominates, so +DI is large and bounded by 100.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut di = PlusDi::new(3).unwrap();
let out: Vec<Option<f64>> = di.batch(&candles);
assert_eq!(out[0], None);
// Seeds after `period` directional moves (candle index `period`).
assert!(out[3].is_some());
let last = out.into_iter().flatten().last().unwrap();
assert!(last > 0.0 && last <= 100.0);
assert!(di.is_ready());
}
#[test]
fn flat_market_returns_zero() {
// No range and no movement: smoothed true range is zero -> +DI is zero.
let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
let mut di = PlusDi::new(3).unwrap();
let last = di.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut di = PlusDi::new(3).unwrap();
let _ = di.batch(&candles);
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(candles[0]), None);
}
}
@@ -0,0 +1,197 @@
//! Plus Directional Movement (+DM), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Plus Directional Movement (`PLUS_DM`).
///
/// The raw plus directional movement of a bar is `max(high high_prev, 0)` when
/// the up-move exceeds the down-move `low_prev low`, and `0` otherwise. This
/// indicator returns the Wilder-smoothed running total of that raw `+DM` over
/// `period` bars, the same accumulation that feeds [`Adx`](crate::Adx) and
/// [`PlusDi`](crate::PlusDi).
///
/// The first `period` raw values seed the sum; from then on each update applies
/// the Wilder recursion `smoothed smoothed / period + raw`. Because a bar's
/// directional movement needs the previous bar, the first value is emitted after
/// `period + 1` candles.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, PlusDm};
///
/// let mut indicator = PlusDm::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct PlusDm {
period: usize,
prev: Option<Candle>,
seed: f64,
seed_count: usize,
smooth: Option<f64>,
}
impl PlusDm {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
seed: 0.0,
seed_count: 0,
smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for PlusDm {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (plus_dm, _) = directional_movement(&prev, &candle);
let n = self.period as f64;
if let Some(s) = self.smooth {
let s_new = s - s / n + plus_dm;
self.smooth = Some(s_new);
return Some(s_new);
}
self.seed += plus_dm;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.smooth = Some(self.seed);
Some(self.seed)
}
fn reset(&mut self) {
self.prev = None;
self.seed = 0.0;
self.seed_count = 0;
self.smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.smooth.is_some()
}
fn name(&self) -> &'static str {
"PLUS_DM"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle with explicit high/low; open and close are pinned to `cl`.
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(PlusDm::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let dm = PlusDm::new(7).unwrap();
assert_eq!(dm.period(), 7);
assert_eq!(dm.name(), "PLUS_DM");
assert_eq!(dm.warmup_period(), 7);
assert!(!dm.is_ready());
}
#[test]
fn seeds_then_smooths_a_constant_plus_dm() {
// High rises by 1 each bar (up = +1); low rises by 0.5 each bar, so the
// down-move is negative and +DM equals the up-move (1.0) on every bar.
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
11.0 + f64::from(i),
9.0 + 0.5 * f64::from(i),
10.0 + f64::from(i),
)
})
.collect();
let mut dm = PlusDm::new(3).unwrap();
let out: Vec<Option<f64>> = dm.batch(&candles);
// First candle only sets the previous bar; bars 2-3 seed the sum.
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_eq!(out[2], None);
// Seed = sum of three unit +DM values.
assert_relative_eq!(out[3].unwrap(), 3.0, epsilon = 1e-12);
// Wilder step: 3 - 3/3 + 1 = 3.
assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
assert!(dm.is_ready());
}
#[test]
fn down_moves_contribute_zero() {
// Strict downtrend: highs fall, so every raw +DM is zero and the smoothed
// total stays at zero.
let candles: Vec<Candle> = (0..6)
.map(|i| c(20.0 - f64::from(i), 5.0 - f64::from(i), 12.0 - f64::from(i)))
.collect();
let mut dm = PlusDm::new(3).unwrap();
let last = dm.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
11.0 + f64::from(i),
9.0 + 0.5 * f64::from(i),
10.0 + f64::from(i),
)
})
.collect();
let mut dm = PlusDm::new(3).unwrap();
let _ = dm.batch(&candles);
assert!(dm.is_ready());
dm.reset();
assert!(!dm.is_ready());
assert_eq!(dm.update(candles[0]), None);
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Rate of Change Percentage (ROCP).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rate of Change Percentage (`ROCP`): `(close - close[period]) / close[period]`.
///
/// The same momentum measure as [`Roc`](crate::Roc) but expressed as a raw
/// fraction rather than a percentage — `Roc` is exactly `100 · ROCP`. Where the
/// reference price is zero the result is reported as `0`.
///
/// Non-finite inputs are ignored and leave the window untouched; the last
/// computed value is returned instead, matching the SMA / EMA convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rocp};
///
/// let mut indicator = Rocp::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rocp {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl Rocp {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period + 1),
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Rocp {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
let prev = *self.window.front().expect("non-empty");
let rocp = if prev == 0.0 {
0.0
} else {
(input - prev) / prev
};
self.last = Some(rocp);
Some(rocp)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"ROCP"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Rocp::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let r = Rocp::new(3).unwrap();
assert_eq!(r.period(), 3);
assert_eq!(r.name(), "ROCP");
assert_eq!(r.warmup_period(), 4);
assert!(!r.is_ready());
}
#[test]
fn known_value_is_a_fraction() {
// period 1 over [10, 11]: (11 - 10) / 10 = 0.1.
let mut r = Rocp::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[10.0, 11.0]);
assert_eq!(out[0], None);
assert_relative_eq!(out[1].unwrap(), 0.1, epsilon = 1e-12);
assert!(r.is_ready());
}
#[test]
fn constant_series_yields_zero() {
let mut r = Rocp::new(3).unwrap();
for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn zero_reference_price_reports_zero() {
// period 1 over [0, 5]: reference price is zero -> guarded to 0.
let mut r = Rocp::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[0.0, 5.0]);
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn non_finite_input_holds_last() {
let mut r = Rocp::new(1).unwrap();
assert_eq!(r.update(10.0), None);
let v = r.update(11.0).unwrap();
assert_eq!(r.update(f64::NAN), Some(v));
}
#[test]
fn reset_clears_state() {
let mut r = Rocp::new(1).unwrap();
let _ = r.batch(&[10.0, 11.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(10.0), None);
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Rate of Change Ratio (ROCR).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rate of Change Ratio (`ROCR`): `close / close[period]`.
///
/// The momentum ratio relative to the price `period` bars ago: `1.0` means no
/// change, `> 1` an advance, `< 1` a decline. It is [`Rocp`](crate::Rocp) plus
/// one. Where the reference price is zero the result is reported as `0`.
///
/// Non-finite inputs are ignored and leave the window untouched; the last
/// computed value is returned instead, matching the SMA / EMA convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rocr};
///
/// let mut indicator = Rocr::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rocr {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl Rocr {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period + 1),
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Rocr {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
let prev = *self.window.front().expect("non-empty");
let rocr = if prev == 0.0 { 0.0 } else { input / prev };
self.last = Some(rocr);
Some(rocr)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"ROCR"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Rocr::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let r = Rocr::new(3).unwrap();
assert_eq!(r.period(), 3);
assert_eq!(r.name(), "ROCR");
assert_eq!(r.warmup_period(), 4);
assert!(!r.is_ready());
}
#[test]
fn known_value_is_a_ratio() {
// period 1 over [10, 11]: 11 / 10 = 1.1.
let mut r = Rocr::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[10.0, 11.0]);
assert_eq!(out[0], None);
assert_relative_eq!(out[1].unwrap(), 1.1, epsilon = 1e-12);
assert!(r.is_ready());
}
#[test]
fn constant_series_yields_one() {
let mut r = Rocr::new(3).unwrap();
for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() {
assert_relative_eq!(*v, 1.0, epsilon = 1e-12);
}
}
#[test]
fn zero_reference_price_reports_zero() {
let mut r = Rocr::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[0.0, 5.0]);
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn non_finite_input_holds_last() {
let mut r = Rocr::new(1).unwrap();
assert_eq!(r.update(10.0), None);
let v = r.update(11.0).unwrap();
assert_eq!(r.update(f64::INFINITY), Some(v));
}
#[test]
fn reset_clears_state() {
let mut r = Rocr::new(1).unwrap();
let _ = r.batch(&[10.0, 11.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(10.0), None);
}
}
@@ -0,0 +1,161 @@
//! Rate of Change Ratio scaled by 100 (ROCR100).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rate of Change Ratio × 100 (`ROCR100`): `close / close[period] · 100`.
///
/// The same ratio as [`Rocr`](crate::Rocr) rescaled so that an unchanged price
/// reads `100` rather than `1`: `> 100` is an advance, `< 100` a decline. Where
/// the reference price is zero the result is reported as `0`.
///
/// Non-finite inputs are ignored and leave the window untouched; the last
/// computed value is returned instead, matching the SMA / EMA convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rocr100};
///
/// let mut indicator = Rocr100::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rocr100 {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl Rocr100 {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period + 1),
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Rocr100 {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
let prev = *self.window.front().expect("non-empty");
let rocr = if prev == 0.0 {
0.0
} else {
input / prev * 100.0
};
self.last = Some(rocr);
Some(rocr)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"ROCR100"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Rocr100::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let r = Rocr100::new(3).unwrap();
assert_eq!(r.period(), 3);
assert_eq!(r.name(), "ROCR100");
assert_eq!(r.warmup_period(), 4);
assert!(!r.is_ready());
}
#[test]
fn known_value_is_a_scaled_ratio() {
// period 1 over [10, 11]: 11 / 10 * 100 = 110.
let mut r = Rocr100::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[10.0, 11.0]);
assert_eq!(out[0], None);
assert_relative_eq!(out[1].unwrap(), 110.0, epsilon = 1e-12);
assert!(r.is_ready());
}
#[test]
fn constant_series_yields_hundred() {
let mut r = Rocr100::new(3).unwrap();
for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() {
assert_relative_eq!(*v, 100.0, epsilon = 1e-12);
}
}
#[test]
fn zero_reference_price_reports_zero() {
let mut r = Rocr100::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[0.0, 5.0]);
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn non_finite_input_holds_last() {
let mut r = Rocr100::new(1).unwrap();
assert_eq!(r.update(10.0), None);
let v = r.update(11.0).unwrap();
assert_eq!(r.update(f64::NEG_INFINITY), Some(v));
}
#[test]
fn reset_clears_state() {
let mut r = Rocr100::new(1).unwrap();
let _ = r.batch(&[10.0, 11.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(10.0), None);
}
}
@@ -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,418 @@
//! Parabolic SAR Extended (SAREXT).
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Trend {
Up,
Down,
}
/// One direction's acceleration-factor schedule (initial, step, maximum).
#[derive(Debug, Clone, Copy)]
struct Accel {
init: f64,
step: f64,
max: f64,
}
impl Accel {
fn validate(self) -> Result<Self> {
if !(self.init.is_finite() && self.step.is_finite() && self.max.is_finite()) {
return Err(Error::NonPositiveMultiplier);
}
if self.init <= 0.0 || self.step <= 0.0 || self.max <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
if self.init > self.max {
return Err(Error::InvalidPeriod {
message: "acceleration init must be <= max",
});
}
Ok(self)
}
}
/// Parabolic SAR Extended (`SAREXT`): Wilder's Parabolic SAR with TA-Lib's
/// extended controls.
///
/// Beyond [`Psar`](crate::Psar) it adds:
/// - **`start_value`** — the initial SAR. `0` auto-seeds (long, like `Psar`);
/// a positive value starts a long phase at that SAR, a negative value starts a
/// short phase at its absolute value.
/// - **`offset_on_reverse`** — a fractional offset applied to the new SAR on each
/// reversal, pushing it further from price (`0` disables it).
/// - **separate long / short acceleration** — independent `(init, step, max)`
/// schedules for rising and falling phases.
///
/// The output is **signed**: a positive value during a long phase (SAR below
/// price) and a negative value during a short phase (SAR above price), so the
/// sign alone encodes the current trade direction.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SarExt};
///
/// let mut indicator =
/// SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SarExt {
start_value: f64,
offset_on_reverse: f64,
long: Accel,
short: Accel,
initialised: bool,
has_emitted: bool,
prev_high: f64,
prev_low: f64,
trend: Trend,
sar: f64,
ep: f64,
af: f64,
}
impl SarExt {
/// Construct an extended Parabolic SAR.
///
/// Parameters mirror TA-Lib's `SAREXT`: `start_value`, `offset_on_reverse`,
/// then the long `(init, step, max)` and short `(init, step, max)`
/// acceleration schedules.
///
/// # Errors
/// Returns [`Error::NonPositiveMultiplier`] if any acceleration term is
/// non-positive or non-finite, [`Error::InvalidPeriod`] if an `init` exceeds
/// its `max`, and [`Error::NonPositiveMultiplier`] if `start_value` or
/// `offset_on_reverse` is non-finite or `offset_on_reverse` is negative.
#[allow(clippy::too_many_arguments)]
pub fn new(
start_value: f64,
offset_on_reverse: f64,
accel_init_long: f64,
accel_long: f64,
accel_max_long: f64,
accel_init_short: f64,
accel_short: f64,
accel_max_short: f64,
) -> Result<Self> {
if !start_value.is_finite() || !offset_on_reverse.is_finite() || offset_on_reverse < 0.0 {
return Err(Error::NonPositiveMultiplier);
}
let long = Accel {
init: accel_init_long,
step: accel_long,
max: accel_max_long,
}
.validate()?;
let short = Accel {
init: accel_init_short,
step: accel_short,
max: accel_max_short,
}
.validate()?;
Ok(Self {
start_value,
offset_on_reverse,
long,
short,
initialised: false,
has_emitted: false,
prev_high: f64::NAN,
prev_low: f64::NAN,
trend: Trend::Up,
sar: f64::NAN,
ep: f64::NAN,
af: long.init,
})
}
/// Wilder's defaults with no start value or reversal offset and symmetric
/// `(0.02, 0.02, 0.20)` acceleration in both directions.
pub fn classic() -> Self {
Self::new(0.0, 0.0, 0.02, 0.02, 0.20, 0.02, 0.02, 0.20)
.expect("classic SAREXT params are valid")
}
fn signed(&self, sar: f64) -> f64 {
match self.trend {
Trend::Up => sar,
Trend::Down => -sar,
}
}
}
impl Indicator for SarExt {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if !self.initialised {
self.prev_high = candle.high;
self.prev_low = candle.low;
if self.start_value > 0.0 {
self.trend = Trend::Up;
self.sar = self.start_value;
self.ep = candle.high;
self.af = self.long.init;
} else if self.start_value < 0.0 {
self.trend = Trend::Down;
self.sar = -self.start_value;
self.ep = candle.low;
self.af = self.short.init;
} else {
self.trend = Trend::Up;
self.sar = candle.low;
self.ep = candle.high;
self.af = self.long.init;
}
self.initialised = true;
return None;
}
let mut new_sar = self.sar + self.af * (self.ep - self.sar);
let prev_h = self.prev_high;
let prev_l = self.prev_low;
new_sar = match self.trend {
Trend::Up => new_sar.min(prev_l).min(candle.low),
Trend::Down => new_sar.max(prev_h).max(candle.high),
};
let mut output_sar = new_sar;
let reversed = match self.trend {
Trend::Up => candle.low <= new_sar,
Trend::Down => candle.high >= new_sar,
};
if reversed {
output_sar = self.ep;
self.trend = match self.trend {
Trend::Up => Trend::Down,
Trend::Down => Trend::Up,
};
match self.trend {
Trend::Up => {
output_sar -= output_sar.abs() * self.offset_on_reverse;
self.ep = candle.high;
self.af = self.long.init;
}
Trend::Down => {
output_sar += output_sar.abs() * self.offset_on_reverse;
self.ep = candle.low;
self.af = self.short.init;
}
}
} else {
match self.trend {
Trend::Up => {
if candle.high > self.ep {
self.ep = candle.high;
self.af = (self.af + self.long.step).min(self.long.max);
}
}
Trend::Down => {
if candle.low < self.ep {
self.ep = candle.low;
self.af = (self.af + self.short.step).min(self.short.max);
}
}
}
}
self.sar = output_sar;
self.prev_high = candle.high;
self.prev_low = candle.low;
self.has_emitted = true;
Some(self.signed(output_sar))
}
fn reset(&mut self) {
self.initialised = false;
self.has_emitted = false;
self.prev_high = f64::NAN;
self.prev_low = f64::NAN;
self.trend = Trend::Up;
self.sar = f64::NAN;
self.ep = f64::NAN;
self.af = self.long.init;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"SAREXT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
fn classic() -> SarExt {
SarExt::classic()
}
#[test]
fn rejects_invalid_params() {
// Non-positive acceleration terms.
assert!(SarExt::new(0.0, 0.0, 0.0, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
assert!(SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.0, 0.02, 0.2).is_err());
assert!(SarExt::new(0.0, 0.0, 0.30, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
// Non-finite acceleration terms hit the finite guard in `Accel::validate`,
// on both the long and the short schedule.
assert!(SarExt::new(0.0, 0.0, f64::NAN, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
assert!(SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, f64::INFINITY, 0.2).is_err());
// Bad start value / offset.
assert!(SarExt::new(f64::NAN, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
assert!(SarExt::new(0.0, -1.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
}
#[test]
fn accessors_and_metadata() {
let s = classic();
assert_eq!(s.warmup_period(), 2);
assert_eq!(s.name(), "SAREXT");
assert!(!s.is_ready());
}
#[test]
fn seed_returns_none_then_emits() {
let mut s = classic();
assert_eq!(s.update(c(11.0, 9.0, 10.0)), None);
assert!(!s.is_ready());
assert!(s.update(c(12.0, 10.0, 11.0)).is_some());
assert!(s.is_ready());
}
#[test]
fn uptrend_is_positive_and_below_lows() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut s = classic();
let ok = s
.batch(&candles)
.iter()
.enumerate()
.all(|(i, v)| v.is_none_or(|x| x > 0.0 && x <= candles[i].low + 1e-9));
assert!(ok, "long-phase SAREXT must be positive and below the low");
}
#[test]
fn downtrend_is_negative_and_above_highs() {
let candles: Vec<Candle> = (0..40)
.rev()
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut s = classic();
let ok = s
.batch(&candles)
.iter()
.enumerate()
.skip(5)
.all(|(i, v)| v.is_none_or(|x| x < 0.0 && -x >= candles[i].high - 1e-9));
assert!(ok, "short-phase SAREXT must be negative and above the high");
}
#[test]
fn positive_start_value_begins_long() {
// start_value > 0 seeds a long phase: first emitted value is positive.
let mut s = SarExt::new(95.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap();
assert_eq!(s.update(c(101.0, 99.0, 100.0)), None);
let v = s.update(c(102.0, 100.0, 101.0)).unwrap();
assert!(v > 0.0);
}
#[test]
fn negative_start_value_begins_short() {
// start_value < 0 seeds a short phase: first emitted value is negative.
let mut s = SarExt::new(-105.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap();
assert_eq!(s.update(c(101.0, 99.0, 100.0)), None);
let v = s.update(c(100.0, 98.0, 99.0)).unwrap();
assert!(v < 0.0);
}
#[test]
fn offset_on_reverse_pushes_sar_further() {
// A V-shaped path forces a reversal; with an offset the reversal SAR is
// pushed further from price than without one.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = if i < 6 {
100.0 - f64::from(i) * 2.0
} else {
88.0 + f64::from(i - 6) * 2.0
};
c(base + 1.0, base - 1.0, base)
})
.collect();
let plain = SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2)
.unwrap()
.batch(&candles);
let offset = SarExt::new(0.0, 0.1, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2)
.unwrap()
.batch(&candles);
// The two configurations must diverge once a reversal with offset fires.
assert_ne!(plain, offset);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut a = classic();
let mut b = classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_allows_clean_reuse() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut s = classic();
let first = s.batch(&candles);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(first, s.batch(&candles));
}
}
@@ -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,148 @@
//! TICK Index — instantaneous net advancing-minus-declining issues.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// TICK Index — the instantaneous net of advancing minus declining issues across
/// a universe, `advancers - decliners`.
///
/// Unlike the cumulative [`AdvanceDecline`](crate::AdvanceDecline) line, the TICK
/// is *not* accumulated: each tick reports the breadth of that snapshot alone. It
/// oscillates around zero — strongly positive readings mean a broad surge of
/// upticks (often an intraday overbought extreme), strongly negative readings a
/// broad flush. Traders fade extremes and watch the zero line for intraday bias.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, Member, TickIndex};
///
/// let mut tick = TickIndex::new();
/// // 2 advancers, 5 decliners -> net -3.
/// let snapshot = CrossSection::new(
/// vec![
/// Member::new(1.0, 10.0, false, false),
/// Member::new(1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// Member::new(-1.0, 10.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(tick.update(snapshot), Some(-3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct TickIndex {
has_emitted: bool,
}
impl TickIndex {
/// Construct a new TICK Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for TickIndex {
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.has_emitted = true;
Some(net)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"TickIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn section(up: usize, down: 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));
}
members.push(Member::new(0.0, 10.0, false, false));
CrossSection::new(members, 0).unwrap()
}
#[test]
fn accessors_and_metadata() {
let tick = TickIndex::new();
assert_eq!(tick.name(), "TickIndex");
assert_eq!(tick.warmup_period(), 1);
assert!(!tick.is_ready());
}
#[test]
fn positive_when_advancers_lead() {
let mut tick = TickIndex::new();
assert_eq!(tick.update(section(5, 2)), Some(3.0));
assert!(tick.is_ready());
}
#[test]
fn negative_when_decliners_lead() {
let mut tick = TickIndex::new();
assert_eq!(tick.update(section(2, 5)), Some(-3.0));
}
#[test]
fn does_not_accumulate() {
let mut tick = TickIndex::new();
// Each tick is independent — the second reading does not carry the first.
assert_eq!(tick.update(section(3, 0)), Some(3.0));
assert_eq!(tick.update(section(0, 1)), Some(-1.0));
}
#[test]
fn reset_clears_state() {
let mut tick = TickIndex::new();
tick.update(section(3, 0));
assert!(tick.is_ready());
tick.reset();
assert!(!tick.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![section(5, 2), section(2, 5), section(3, 0), section(0, 1)];
let mut a = TickIndex::new();
let mut b = TickIndex::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
+171
View File
@@ -0,0 +1,171 @@
//! TRIN / Arms Index — the advance-decline ratio over the up-down volume ratio.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// TRIN (Arms Index) — `(advancers / decliners) / (advancing volume / declining
/// volume)`.
///
/// The TRIN compares the breadth of a move in *issues* to the breadth of the move
/// in *volume*. A value near `1.0` means advancing issues and advancing volume are
/// in balance; a value below `1.0` is bullish (volume is concentrated in advancing
/// issues relative to their count); a value above `1.0` is bearish (declining
/// issues are absorbing disproportionate volume).
///
/// To stay finite on degenerate ticks the decliner count is floored to one and
/// both volume sums are floored to `1.0`, so a tick with no declining issues or no
/// volume on one side still yields a defined reading instead of a division by
/// zero.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, Member, Trin};
///
/// let mut trin = Trin::new();
/// // 3 advancers / 1 decliner = 3; adv vol 150 / dec vol 50 = 3; TRIN = 1.0.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 50.0, false, false),
/// Member::new(1.0, 50.0, false, false),
/// Member::new(1.0, 50.0, false, false),
/// Member::new(-1.0, 50.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(trin.update(tick), Some(1.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct Trin {
has_emitted: bool,
}
impl Trin {
/// Construct a new TRIN / Arms Index indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for Trin {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let advancers = section.advancers() as f64;
let decliners = section.decliners().max(1) as f64;
let advancing_volume = section.advancing_volume().max(1.0);
let declining_volume = section.declining_volume().max(1.0);
let ad_ratio = advancers / decliners;
let volume_ratio = advancing_volume / declining_volume;
self.has_emitted = true;
Some(ad_ratio / volume_ratio)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"Trin"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(items: &[(f64, f64)]) -> CrossSection {
CrossSection::new(
items
.iter()
.map(|&(change, volume)| Member::new(change, volume, false, false))
.collect(),
0,
)
.unwrap()
}
#[test]
fn accessors_and_metadata() {
let trin = Trin::new();
assert_eq!(trin.name(), "Trin");
assert_eq!(trin.warmup_period(), 1);
assert!(!trin.is_ready());
}
#[test]
fn balanced_breadth_yields_one() {
let mut trin = Trin::new();
let value = trin
.update(tick(&[(1.0, 50.0), (1.0, 50.0), (1.0, 50.0), (-1.0, 50.0)]))
.unwrap();
assert!((value - 1.0).abs() < 1e-9);
assert!(trin.is_ready());
}
#[test]
fn zero_decliners_and_volume_are_floored() {
let mut trin = Trin::new();
// 2 advancers, 0 decliners, adv vol 100, dec vol 0.
// ad_ratio = 2 / max(0,1) = 2; volume_ratio = 100 / max(0,1) = 100; TRIN = 0.02.
let value = trin.update(tick(&[(1.0, 50.0), (1.0, 50.0)])).unwrap();
assert!((value - 0.02).abs() < 1e-9);
}
#[test]
fn heavy_declining_volume_pushes_above_one() {
let mut trin = Trin::new();
// 2 adv / 2 dec = 1; adv vol 20 / dec vol 80 = 0.25; TRIN = 4.0.
let value = trin
.update(tick(&[
(1.0, 10.0),
(1.0, 10.0),
(-1.0, 40.0),
(-1.0, 40.0),
]))
.unwrap();
assert!((value - 4.0).abs() < 1e-9);
}
#[test]
fn reset_clears_state() {
let mut trin = Trin::new();
trin.update(tick(&[(1.0, 10.0), (-1.0, 10.0)]));
assert!(trin.is_ready());
trin.reset();
assert!(!trin.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
tick(&[(1.0, 50.0), (1.0, 50.0), (1.0, 50.0), (-1.0, 50.0)]),
tick(&[(1.0, 50.0), (1.0, 50.0)]),
tick(&[(1.0, 10.0), (1.0, 10.0), (-1.0, 40.0), (-1.0, 40.0)]),
];
let mut a = Trin::new();
let mut b = Trin::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
+169
View File
@@ -0,0 +1,169 @@
//! Time Series Forecast (TSF).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Time Series Forecast (`TSF`): the rolling least-squares line projected one bar
/// past the window.
///
/// Over the last `period` inputs, indexed `x = 0, 1, …, period 1`, it fits
/// `y = a + b·x` by ordinary least squares and reports the line's value at
/// `x = period` (one step beyond the most recent point):
///
/// ```text
/// b (slope) = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// a (intercept) = (Σy b·Σx) / n
/// TSF = a + b·period
/// ```
///
/// Where [`LinearRegression`](crate::LinearRegression) evaluates the fit at the
/// current bar (`a + b·(period 1)`), `TSF` advances it one further bar, giving a
/// trend-following one-step-ahead forecast. Each update is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Tsf};
///
/// let mut indicator = Tsf::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Tsf {
period: usize,
window: VecDeque<f64>,
sum_x: f64,
denom: f64,
sum_y: f64,
sum_xy: f64,
}
impl Tsf {
/// Construct a new rolling time-series forecast over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
/// undefined for fewer than two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "time series forecast needs period >= 2",
});
}
let n = period as f64;
let sum_x = n * (n - 1.0) / 2.0;
let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_x,
denom: n * sum_xx - sum_x * sum_x,
sum_y: 0.0,
sum_xy: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Tsf {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
self.sum_y -= y0;
}
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom;
let intercept = (self.sum_y - slope * self.sum_x) / n;
Some(intercept + slope * n)
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"TSF"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_short_period() {
assert!(matches!(Tsf::new(1), Err(Error::InvalidPeriod { .. })));
}
#[test]
fn accessors_report_config() {
let tsf = Tsf::new(5).unwrap();
assert_eq!(tsf.period(), 5);
assert_eq!(tsf.name(), "TSF");
assert_eq!(tsf.warmup_period(), 5);
assert!(!tsf.is_ready());
}
#[test]
fn reference_value() {
// period 3 over [1, 2, 9]: fit y = 0 + 4x, forecast at x = 3 is 12.
let mut tsf = Tsf::new(3).unwrap();
let out: Vec<Option<f64>> = tsf.batch(&[1.0, 2.0, 9.0]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-9);
assert!(tsf.is_ready());
}
#[test]
fn forecasts_a_clean_line_one_step_ahead() {
// Window [10, 12, 14]: y = 10 + 2x, forecast at x = 3 is 16.
let mut tsf = Tsf::new(3).unwrap();
let out: Vec<Option<f64>> = tsf.batch(&[1.0, 10.0, 12.0, 14.0]);
assert_relative_eq!(out[3].unwrap(), 16.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut tsf = Tsf::new(3).unwrap();
let _ = tsf.batch(&[1.0, 2.0, 9.0]);
assert!(tsf.is_ready());
tsf.reset();
assert!(!tsf.is_ready());
assert_eq!(tsf.update(1.0), None);
}
}
@@ -0,0 +1,143 @@
//! Up/Down Volume Ratio — advancing volume divided by declining volume.
use crate::cross_section::CrossSection;
use crate::traits::Indicator;
/// Up/Down Volume Ratio — total advancing volume divided by total declining
/// volume across a universe.
///
/// On each [`CrossSection`] tick the ratio is `advancing volume / declining
/// volume`. A reading above one means more volume is trading in advancing issues
/// than declining ones (accumulation); a reading below one means distribution.
/// Sustained extremes are used to flag breadth thrusts and washout bottoms.
///
/// When a tick has no declining volume the denominator is floored to `1.0`, so the
/// ratio stays finite (it degrades to the advancing-volume total) instead of
/// dividing by zero.
///
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{CrossSection, Indicator, Member, UpDownVolumeRatio};
///
/// let mut udv = UpDownVolumeRatio::new();
/// // advancing volume 150, declining volume 50 -> ratio 3.0.
/// let tick = CrossSection::new(
/// vec![
/// Member::new(1.0, 150.0, false, false),
/// Member::new(-1.0, 50.0, false, false),
/// ],
/// 0,
/// )
/// .unwrap();
/// assert_eq!(udv.update(tick), Some(3.0));
/// ```
#[derive(Debug, Clone, Default)]
pub struct UpDownVolumeRatio {
has_emitted: bool,
}
impl UpDownVolumeRatio {
/// Construct a new Up/Down Volume Ratio indicator.
#[must_use]
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for UpDownVolumeRatio {
type Input = CrossSection;
type Output = f64;
fn update(&mut self, section: CrossSection) -> Option<f64> {
let advancing_volume = section.advancing_volume();
let declining_volume = section.declining_volume().max(1.0);
self.has_emitted = true;
Some(advancing_volume / declining_volume)
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"UpDownVolumeRatio"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cross_section::Member;
use crate::traits::BatchExt;
fn tick(items: &[(f64, f64)]) -> CrossSection {
CrossSection::new(
items
.iter()
.map(|&(change, volume)| Member::new(change, volume, false, false))
.collect(),
0,
)
.unwrap()
}
#[test]
fn accessors_and_metadata() {
let udv = UpDownVolumeRatio::new();
assert_eq!(udv.name(), "UpDownVolumeRatio");
assert_eq!(udv.warmup_period(), 1);
assert!(!udv.is_ready());
}
#[test]
fn first_tick_emits_ratio() {
let mut udv = UpDownVolumeRatio::new();
assert_eq!(udv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(3.0));
assert!(udv.is_ready());
}
#[test]
fn zero_declining_volume_floors_denominator() {
let mut udv = UpDownVolumeRatio::new();
// advancing volume 100, declining volume 0 -> 100 / max(0, 1) = 100.0.
assert_eq!(udv.update(tick(&[(1.0, 100.0)])), Some(100.0));
}
#[test]
fn reset_clears_state() {
let mut udv = UpDownVolumeRatio::new();
udv.update(tick(&[(1.0, 10.0), (-1.0, 10.0)]));
assert!(udv.is_ready());
udv.reset();
assert!(!udv.is_ready());
}
#[test]
fn batch_equals_streaming() {
let sections = vec![
tick(&[(1.0, 150.0), (-1.0, 50.0)]),
tick(&[(1.0, 100.0)]),
tick(&[(1.0, 20.0), (-1.0, 80.0)]),
];
let mut a = UpDownVolumeRatio::new();
let mut b = UpDownVolumeRatio::new();
assert_eq!(
a.batch(&sections),
sections
.iter()
.map(|s| b.update(s.clone()))
.collect::<Vec<_>>()
);
}
}
@@ -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);
}
}
+56 -44
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,71 +51,82 @@ 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,
AbandonedBaby, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput,
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, Adl, AdvanceBlock,
AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma,
Alpha, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands,
AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, 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,
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration,
AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, AvgPrice, AwesomeOscillator,
AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta, BetaNeutralSpread, BollingerBands,
BollingerBandwidth, BollingerOutput, BreadthThrust, Breakaway, BullishPercentIndex,
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, CumulativeVolumeIndex, 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,
EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, Fama,
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, 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, LinRegSlope,
LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongLine,
LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput,
HiLoActivator, HighLowIndex, 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,
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, 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, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread,
RSquared, RealizedSpread, RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput,
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, RogersSatchellVolatility,
RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SeparatingLines,
SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, Sma, Smi, Smma,
SortinoRatio, SpearmanCorrelation, SpinningTop, StalledPattern, StandardError,
McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation,
MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar,
MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio,
OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OuHalfLife, PainIndex,
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
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, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside, ThreeLineStrike,
ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, Tii, TpoProfile,
TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, 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,
ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TpoProfile,
TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trin, Trix, TrueRange, Tsf, Tsi, Tsv,
TtmSqueeze, TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
ValueAreaOutput, 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
// strips only `*Output` companions) does not count it as a separate indicator.
pub use indicators::FootprintLevel;
// `MaType` is a moving-average selector enum used by `MacdExt`, re-exported on
// its own line so the indicator-count tooling does not count it as an indicator.
pub use indicators::MaType;
// Bar element types for the alt-chart builders, re-exported on their own lines so
// the indicator-count tooling (which scans only the braced block above) does not
// count them as separate indicators.
+6
View File
@@ -119,6 +119,12 @@ impl Candle {
(self.high + self.low + 2.0 * self.close) / 4.0
}
/// The average price `(open + high + low + close) / 4`.
#[inline]
pub fn avg_price(&self) -> f64 {
(self.open + self.high + self.low + self.close) / 4.0
}
/// True range of this candle relative to a previous close: `max(H-L, |H-prev|, |L-prev|)`.
/// If no previous close is supplied, falls back to `high - low`.
#[inline]
+1 -1
View File
@@ -5,7 +5,7 @@ version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+1 -1
View File
@@ -5,7 +5,7 @@ version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme.workspace = true
+1 -2
View File
@@ -21,10 +21,9 @@ allow-wildcard-paths = true
[licenses]
version = 2
# Licenses permitted for every crate in the dependency graph. Wickra itself is
# PolyForm-Noncommercial-1.0.0; the rest are the permissive licenses its
# dual-licensed MIT OR Apache-2.0; the rest are the permissive licenses its
# dependency tree actually uses.
allow = [
"PolyForm-Noncommercial-1.0.0",
"MIT",
"Apache-2.0",
"BSD-2-Clause",
+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 **295 indicators** across
- A per-indicator deep dive for every one of the **339 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 &
+8 -8
View File
@@ -17,8 +17,8 @@
},
"../../bindings/node": {
"name": "wickra",
"version": "0.4.5",
"license": "PolyForm-Noncommercial-1.0.0",
"version": "0.5.0",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
},
@@ -26,12 +26,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.4.5",
"wickra-darwin-x64": "0.4.5",
"wickra-linux-arm64-gnu": "0.4.5",
"wickra-linux-x64-gnu": "0.4.5",
"wickra-win32-arm64-msvc": "0.4.5",
"wickra-win32-x64-msvc": "0.4.5"
"wickra-darwin-arm64": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-linux-x64-gnu": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0"
}
},
"node_modules/wickra": {
+1 -1
View File
@@ -6,7 +6,7 @@ description = "Runnable Rust examples for the Wickra technical-analysis library.
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license-file.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
readme = "README.md"
+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"
+39 -15
View File
@@ -15,21 +15,7 @@
use libfuzzer_sys::fuzz_target;
use wickra_core::{
AdaptiveCycle, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta,
BollingerBands,
CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk,
ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev,
DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Fama, FisherTransform, Frama, GainLossRatio, HilbertDominantCycle,
HistoricalVolatility, Hma, HurstExponent, Indicator, InstantaneousTrendline,
InverseFisherTransform, Jma, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle,
LinRegChannel, LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, Mama, MaxDrawdown,
McGinleyDynamic, MedianAbsoluteDeviation, Mom, OmegaRatio, PainIndex, PearsonCorrelation,
PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RecoveryFactor, RenkoTrailingStop,
Roc, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, Skewness, Sma, Smma,
SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev,
StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, Trima, Trix, Tsi, UlcerIndex,
ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, Zlema, T3,
AdaptiveCycle, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Fama, FisherTransform, Frama, GainLossRatio, HilbertDominantCycle, HistoricalVolatility, Hma, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RecoveryFactor, RenkoTrailingStop, Roc, Rocp, Rocr, Rocr100, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, Trima, Trix, Tsf, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, Zlema, T3
};
/// Drive a single streaming + batch run through one scalar indicator. Marked
@@ -60,6 +46,9 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| Tema::new(14).unwrap(), &data);
drive(|| Hma::new(14).unwrap(), &data);
drive(|| Roc::new(14).unwrap(), &data);
drive(|| Rocp::new(14).unwrap(), &data);
drive(|| Rocr::new(14).unwrap(), &data);
drive(|| Rocr100::new(14).unwrap(), &data);
drive(|| Trix::new(14).unwrap(), &data);
drive(|| Smma::new(14).unwrap(), &data);
drive(|| Trima::new(14).unwrap(), &data);
@@ -88,7 +77,10 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| UlcerIndex::new(14).unwrap(), &data);
drive(|| HistoricalVolatility::new(14, 252).unwrap(), &data);
drive(|| LinearRegression::new(14).unwrap(), &data);
drive(|| MidPoint::new(14).unwrap(), &data);
drive(|| LinRegSlope::new(14).unwrap(), &data);
drive(|| LinRegIntercept::new(14).unwrap(), &data);
drive(|| Tsf::new(14).unwrap(), &data);
drive(|| LinRegAngle::new(14).unwrap(), &data);
drive(|| VerticalHorizontalFilter::new(14).unwrap(), &data);
drive(|| ZScore::new(14).unwrap(), &data);
@@ -146,6 +138,8 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| EhlersStochastic::new(20).unwrap(), &data);
drive(|| EmpiricalModeDecomposition::new(20, 0.5).unwrap(), &data);
drive(HilbertDominantCycle::new, &data);
drive(HtDcPhase::new, &data);
drive(HtTrendMode::new, &data);
drive(AdaptiveCycle::new, &data);
drive(SineWave::new, &data);
drive(|| Fama::new(0.5, 0.05).unwrap(), &data);
@@ -191,6 +185,36 @@ fuzz_target!(|data: Vec<f64>| {
}
let _ = MacdIndicator::new(12, 26, 9).unwrap().batch(&data);
}
// MACDFIX wraps MacdIndicator(12, 26, signal); same multi-output topology.
{
let mut fix = MacdFix::new(9).unwrap();
for &x in &data {
let _ = fix.update(x);
}
let _ = MacdFix::new(9).unwrap().batch(&data);
}
// MACDEXT: selectable MA types per line, multi-output topology.
{
let mut ext = MacdExt::new(12, MaType::Ema, 26, MaType::Ema, 9, MaType::Sma).unwrap();
for &x in &data {
let _ = ext.update(x);
}
let _ = MacdExt::new(12, MaType::Ema, 26, MaType::Ema, 9, MaType::Sma)
.unwrap()
.batch(&data);
}
// HT_PHASOR is scalar-input but emits a {inphase, quadrature} struct, so it
// bypasses the generic `drive` helper.
{
let mut ph = HtPhasor::new();
for &x in &data {
let _ = ph.update(x);
}
let _ = HtPhasor::new().batch(&data);
}
{
let mut bb = BollingerBands::new(20, 2.0).unwrap();
for &x in &data {
+9 -1
View File
@@ -23,7 +23,7 @@
use libfuzzer_sys::fuzz_target;
use wickra_core::{
AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MedianPrice, Mfi, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, ParkinsonVolatility, Pgo, PiercingDarkCloud, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SeparatingLines, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TpoProfile, TrueRange, Tsv, TtmSqueeze, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag
AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeparatingLines, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TpoProfile, TrueRange, Tsv, TtmSqueeze, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag
};
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
@@ -79,6 +79,7 @@ fuzz_target!(|data: Vec<f64>| {
// --- Trailing Stops ---
drive(|| Psar::new(0.02, 0.02, 0.20).unwrap(), &candles);
drive(SarExt::classic, &candles);
drive(|| SuperTrend::new(14, 3.0).unwrap(), &candles);
drive(|| ChandelierExit::new(22, 3.0).unwrap(), &candles);
drive(|| ChandeKrollStop::new(10, 1.0, 9).unwrap(), &candles);
@@ -90,6 +91,11 @@ fuzz_target!(|data: Vec<f64>| {
// --- Trend & Directional ---
drive(|| Adx::new(14).unwrap(), &candles);
drive(|| Adxr::new(14).unwrap(), &candles);
drive(|| PlusDm::new(14).unwrap(), &candles);
drive(|| MinusDm::new(14).unwrap(), &candles);
drive(|| PlusDi::new(14).unwrap(), &candles);
drive(|| MinusDi::new(14).unwrap(), &candles);
drive(|| Dx::new(14).unwrap(), &candles);
drive(|| Aroon::new(14).unwrap(), &candles);
drive(|| Alligator::new(13, 8, 5).unwrap(), &candles);
drive(|| AroonOscillator::new(14).unwrap(), &candles);
@@ -143,6 +149,8 @@ fuzz_target!(|data: Vec<f64>| {
drive(TypicalPrice::new, &candles);
drive(MedianPrice::new, &candles);
drive(WeightedClose::new, &candles);
drive(|| MidPrice::new(14).unwrap(), &candles);
drive(AvgPrice::new, &candles);
// --- Stochastic (multi-output) ---
{
@@ -0,0 +1,61 @@
#![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::{AbsoluteBreadthIndex, AdVolumeLine, AdvanceDecline, AdvanceDeclineRatio, BatchExt, BreadthThrust, BullishPercentIndex, CrossSection, CumulativeVolumeIndex, HighLowIndex, Indicator, McClellanOscillator, McClellanSummationIndex, Member, NewHighsNewLows, PercentAboveMa, TickIndex, Trin, UpDownVolumeRatio};
#[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);
drive(AdvanceDeclineRatio::new, &sections);
drive(AdVolumeLine::new, &sections);
drive(McClellanOscillator::new, &sections);
drive(McClellanSummationIndex::new, &sections);
drive(Trin::new, &sections);
drive(|| BreadthThrust::new(10).unwrap(), &sections);
drive(NewHighsNewLows::new, &sections);
drive(|| HighLowIndex::new(10).unwrap(), &sections);
drive(PercentAboveMa::new, &sections);
drive(UpDownVolumeRatio::new, &sections);
drive(BullishPercentIndex::new, &sections);
drive(CumulativeVolumeIndex::new, &sections);
drive(AbsoluteBreadthIndex::new, &sections);
drive(TickIndex::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);
});